mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[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
This commit is contained in:
@@ -105,6 +105,7 @@
|
||||
[group]="formGroup.controls.paymentMethod"
|
||||
[showBankAccount]="false"
|
||||
[showAccountCredit]="true"
|
||||
[hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async"
|
||||
>
|
||||
</app-enter-payment-method>
|
||||
<app-enter-billing-address
|
||||
@@ -123,7 +124,13 @@
|
||||
<p bitTypography="body1">
|
||||
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
|
||||
</p>
|
||||
<button type="submit" buttonType="primary" bitButton bitFormButton>
|
||||
<button
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
bitButton
|
||||
bitFormButton
|
||||
[disabled]="!(hasEnoughAccountCredit$ | async)"
|
||||
>
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
</bit-section>
|
||||
|
||||
@@ -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<boolean>;
|
||||
protected accountCredit$: Observable<number>;
|
||||
protected hasEnoughAccountCredit$: Observable<boolean>;
|
||||
|
||||
protected formGroup = new FormGroup({
|
||||
additionalStorage: new FormControl<number>(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);
|
||||
|
||||
@@ -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<PaymentMethodOption>;
|
||||
@@ -183,9 +184,15 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
}
|
||||
@case ("accountCredit") {
|
||||
<ng-container>
|
||||
<bit-callout type="info">
|
||||
{{ "makeSureEnoughCredit" | i18n }}
|
||||
</bit-callout>
|
||||
@if (hasEnoughAccountCredit) {
|
||||
<bit-callout type="info">
|
||||
{{ "makeSureEnoughCredit" | i18n }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout type="warning">
|
||||
{{ "notEnoughAccountCredit" | i18n }}
|
||||
</bit-callout>
|
||||
}
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user