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"
|
[group]="formGroup.controls.paymentMethod"
|
||||||
[showBankAccount]="false"
|
[showBankAccount]="false"
|
||||||
[showAccountCredit]="true"
|
[showAccountCredit]="true"
|
||||||
|
[hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async"
|
||||||
>
|
>
|
||||||
</app-enter-payment-method>
|
</app-enter-payment-method>
|
||||||
<app-enter-billing-address
|
<app-enter-billing-address
|
||||||
@@ -123,7 +124,13 @@
|
|||||||
<p bitTypography="body1">
|
<p bitTypography="body1">
|
||||||
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
|
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
|
||||||
</p>
|
</p>
|
||||||
<button type="submit" buttonType="primary" bitButton bitFormButton>
|
<button
|
||||||
|
type="submit"
|
||||||
|
buttonType="primary"
|
||||||
|
bitButton
|
||||||
|
bitFormButton
|
||||||
|
[disabled]="!(hasEnoughAccountCredit$ | async)"
|
||||||
|
>
|
||||||
{{ "submit" | i18n }}
|
{{ "submit" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
</bit-section>
|
</bit-section>
|
||||||
|
|||||||
@@ -4,34 +4,41 @@ import { Component, ViewChild } from "@angular/core";
|
|||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||||
import { ActivatedRoute, Router } from "@angular/router";
|
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 { debounceTime } from "rxjs/operators";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||||
|
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||||
import { ToastService } from "@bitwarden/components";
|
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 {
|
import {
|
||||||
EnterBillingAddressComponent,
|
EnterBillingAddressComponent,
|
||||||
EnterPaymentMethodComponent,
|
EnterPaymentMethodComponent,
|
||||||
getBillingAddressFromForm,
|
getBillingAddressFromForm,
|
||||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
} 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({
|
@Component({
|
||||||
templateUrl: "./premium.component.html",
|
templateUrl: "./premium.component.html",
|
||||||
standalone: false,
|
standalone: false,
|
||||||
providers: [TaxClient],
|
providers: [SubscriberBillingClient, TaxClient],
|
||||||
})
|
})
|
||||||
export class PremiumComponent {
|
export class PremiumComponent {
|
||||||
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
|
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
|
||||||
|
|
||||||
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
|
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
|
||||||
|
protected accountCredit$: Observable<number>;
|
||||||
|
protected hasEnoughAccountCredit$: Observable<boolean>;
|
||||||
|
|
||||||
protected formGroup = new FormGroup({
|
protected formGroup = new FormGroup({
|
||||||
additionalStorage: new FormControl<number>(0, [Validators.min(0), Validators.max(99)]),
|
additionalStorage: new FormControl<number>(0, [Validators.min(0), Validators.max(99)]),
|
||||||
@@ -58,6 +65,7 @@ export class PremiumComponent {
|
|||||||
private syncService: SyncService,
|
private syncService: SyncService,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
|
private subscriberBillingClient: SubscriberBillingClient,
|
||||||
private taxClient: TaxClient,
|
private taxClient: TaxClient,
|
||||||
) {
|
) {
|
||||||
this.isSelfHost = this.platformUtilsService.isSelfHost();
|
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([
|
combineLatest([
|
||||||
this.accountService.activeAccount$.pipe(
|
this.accountService.activeAccount$.pipe(
|
||||||
switchMap((account) =>
|
switchMap((account) =>
|
||||||
@@ -120,13 +148,26 @@ export class PremiumComponent {
|
|||||||
return;
|
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();
|
const formData = new FormData();
|
||||||
formData.append("paymentMethodType", legacyEnum.toString());
|
formData.append("paymentMethodType", paymentMethodType.toString());
|
||||||
formData.append("paymentToken", paymentMethod.token);
|
formData.append("paymentToken", paymentToken);
|
||||||
formData.append("additionalStorageGb", this.formGroup.value.additionalStorage.toString());
|
formData.append("additionalStorageGb", this.formGroup.value.additionalStorage.toString());
|
||||||
formData.append("country", this.formGroup.value.billingAddress.country);
|
formData.append("country", this.formGroup.value.billingAddress.country);
|
||||||
formData.append("postalCode", this.formGroup.value.billingAddress.postalCode);
|
formData.append("postalCode", this.formGroup.value.billingAddress.postalCode);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PopoverModule, ToastService } from "@bitwarden/components";
|
|||||||
import { SharedModule } from "../../../shared";
|
import { SharedModule } from "../../../shared";
|
||||||
import { BillingServicesModule, BraintreeService, StripeService } from "../../services";
|
import { BillingServicesModule, BraintreeService, StripeService } from "../../services";
|
||||||
import {
|
import {
|
||||||
|
AccountCreditPaymentMethod,
|
||||||
isTokenizablePaymentMethod,
|
isTokenizablePaymentMethod,
|
||||||
selectableCountries,
|
selectableCountries,
|
||||||
TokenizablePaymentMethod,
|
TokenizablePaymentMethod,
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
|
|
||||||
import { PaymentLabelComponent } from "./payment-label.component";
|
import { PaymentLabelComponent } from "./payment-label.component";
|
||||||
|
|
||||||
type PaymentMethodOption = TokenizablePaymentMethod | "accountCredit";
|
type PaymentMethodOption = TokenizablePaymentMethod | AccountCreditPaymentMethod;
|
||||||
|
|
||||||
type PaymentMethodFormGroup = FormGroup<{
|
type PaymentMethodFormGroup = FormGroup<{
|
||||||
type: FormControl<PaymentMethodOption>;
|
type: FormControl<PaymentMethodOption>;
|
||||||
@@ -183,9 +184,15 @@ type PaymentMethodFormGroup = FormGroup<{
|
|||||||
}
|
}
|
||||||
@case ("accountCredit") {
|
@case ("accountCredit") {
|
||||||
<ng-container>
|
<ng-container>
|
||||||
<bit-callout type="info">
|
@if (hasEnoughAccountCredit) {
|
||||||
{{ "makeSureEnoughCredit" | i18n }}
|
<bit-callout type="info">
|
||||||
</bit-callout>
|
{{ "makeSureEnoughCredit" | i18n }}
|
||||||
|
</bit-callout>
|
||||||
|
} @else {
|
||||||
|
<bit-callout type="warning">
|
||||||
|
{{ "notEnoughAccountCredit" | i18n }}
|
||||||
|
</bit-callout>
|
||||||
|
}
|
||||||
</ng-container>
|
</ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +237,7 @@ export class EnterPaymentMethodComponent implements OnInit {
|
|||||||
@Input() private showBankAccount = true;
|
@Input() private showBankAccount = true;
|
||||||
@Input() showPayPal = true;
|
@Input() showPayPal = true;
|
||||||
@Input() showAccountCredit = false;
|
@Input() showAccountCredit = false;
|
||||||
|
@Input() hasEnoughAccountCredit = true;
|
||||||
@Input() includeBillingAddress = false;
|
@Input() includeBillingAddress = false;
|
||||||
|
|
||||||
protected showBankAccount$!: Observable<boolean>;
|
protected showBankAccount$!: Observable<boolean>;
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ export const TokenizablePaymentMethods = {
|
|||||||
payPal: "payPal",
|
payPal: "payPal",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const NonTokenizablePaymentMethods = {
|
||||||
|
accountCredit: "accountCredit",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export type BankAccountPaymentMethod = typeof TokenizablePaymentMethods.bankAccount;
|
export type BankAccountPaymentMethod = typeof TokenizablePaymentMethods.bankAccount;
|
||||||
export type CardPaymentMethod = typeof TokenizablePaymentMethods.card;
|
export type CardPaymentMethod = typeof TokenizablePaymentMethods.card;
|
||||||
export type PayPalPaymentMethod = typeof TokenizablePaymentMethods.payPal;
|
export type PayPalPaymentMethod = typeof TokenizablePaymentMethods.payPal;
|
||||||
|
export type AccountCreditPaymentMethod = typeof NonTokenizablePaymentMethods.accountCredit;
|
||||||
|
|
||||||
export type TokenizablePaymentMethod =
|
export type TokenizablePaymentMethod =
|
||||||
(typeof TokenizablePaymentMethods)[keyof typeof TokenizablePaymentMethods];
|
(typeof TokenizablePaymentMethods)[keyof typeof TokenizablePaymentMethods];
|
||||||
@@ -18,21 +23,6 @@ export const isTokenizablePaymentMethod = (value: string): value is TokenizableP
|
|||||||
return valid.includes(value);
|
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 = (
|
export const tokenizablePaymentMethodToLegacyEnum = (
|
||||||
paymentMethod: TokenizablePaymentMethod,
|
paymentMethod: TokenizablePaymentMethod,
|
||||||
): PaymentMethodType => {
|
): PaymentMethodType => {
|
||||||
|
|||||||
@@ -2951,6 +2951,9 @@
|
|||||||
"makeSureEnoughCredit": {
|
"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."
|
"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": {
|
"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."
|
"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