1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 02:51:24 +00:00
Files
browser/apps/web/src/app/billing/payment/components/display-payment-method-inline.component.ts
Stephon Brown f667507512 [PM-32028] Remove Save and Cancel Buttons (#18954)
* feat(billing): Refactor DisplayPaymentMethodInlineComponent for external form control

* feat(billing): Integrate external payment method management in PremiumOrgUpgradePayment
Cleanup: Remove debug console.warn in invoice preview refresh

* test(billing): Update PremiumOrgUpgradePaymentComponent tests

* refactor:add non-null assertion for payment method validation

* refactor: use string selectors for ViewChild

* refactor: remove unused `tap` operator

* test: improve component mocking setup

* feat: add payment method validation on upgrade

* refactor(billing): remove unused updatePaymentInParent input
2026-02-24 14:07:43 -06:00

272 lines
8.5 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
viewChild,
} from "@angular/core";
import { FormGroup } from "@angular/forms";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService, IconComponent } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
import { SharedModule } from "../../../shared";
import { BitwardenSubscriber } from "../../types";
import { getCardBrandIcon, MaskedPaymentMethod, TokenizablePaymentMethods } from "../types";
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
/**
* Component for inline editing of payment methods.
* Displays a form to update payment method details directly within the parent view.
*/
@Component({
selector: "app-display-payment-method-inline",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<bit-section>
@if (!isChangingPayment()) {
<h5 bitTypography="h5">{{ "paymentMethod" | i18n }}</h5>
<div class="tw-flex tw-items-center tw-gap-2">
@if (paymentMethod(); as pm) {
@switch (pm.type) {
@case ("bankAccount") {
@if (pm.hostedVerificationUrl) {
<p>
{{ "verifyBankAccountWithStripe" | i18n }}
<a
bitLink
rel="noreferrer"
target="_blank"
[attr.href]="pm.hostedVerificationUrl"
>{{ "verifyNow" | i18n }}</a
>
</p>
}
<p>
<bit-icon name="bwi-billing"></bit-icon>
{{ pm.bankName }}, *{{ pm.last4 }}
@if (pm.hostedVerificationUrl) {
<span>- {{ "unverified" | i18n }}</span>
}
</p>
}
@case ("card") {
<p class="tw-flex tw-gap-2">
@if (cardBrandIcon(); as icon) {
<i class="bwi bwi-fw credit-card-icon {{ icon }}"></i>
} @else {
<bit-icon name="bwi-credit-card"></bit-icon>
}
{{ pm.brand | titlecase }}, *{{ pm.last4 }},
{{ pm.expiration }}
</p>
}
@case ("payPal") {
<p>
<bit-icon name="bwi-paypal" class="tw-text-primary-600"></bit-icon>
{{ pm.email }}
</p>
}
}
} @else {
<p bitTypography="body1">{{ "noPaymentMethod" | i18n }}</p>
}
@let key = paymentMethod() ? "changePaymentMethod" : "addPaymentMethod";
<a
bitLink
linkType="primary"
class="tw-cursor-pointer tw-mb-4"
(click)="changePaymentMethod()"
>
{{ key | i18n }}</a
>
</div>
} @else {
<app-enter-payment-method
#enterPaymentMethodComponent
[includeBillingAddress]="false"
[group]="formGroup"
[showBankAccount]="true"
[showAccountCredit]="false"
>
</app-enter-payment-method>
@if (showFormButtons()) {
<div class="tw-mt-4 tw-flex tw-gap-2">
<button
bitLink
linkType="default"
type="button"
(click)="submit()"
[disabled]="formGroup.invalid"
>
{{ "save" | i18n }}
</button>
<button bitLink linkType="subtle" type="button" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</div>
}
}
</bit-section>
`,
standalone: true,
imports: [SharedModule, EnterPaymentMethodComponent, IconComponent],
providers: [SubscriberBillingClient],
})
export class DisplayPaymentMethodInlineComponent {
readonly subscriber = input.required<BitwardenSubscriber>();
readonly paymentMethod = input.required<MaskedPaymentMethod | null>();
readonly externalFormGroup = input<FormGroup | null>(null);
readonly updated = output<MaskedPaymentMethod>();
protected formGroup: FormGroup;
private readonly enterPaymentMethodComponent = viewChild<EnterPaymentMethodComponent>(
EnterPaymentMethodComponent,
);
readonly isChangingPayment = signal(false);
protected readonly cardBrandIcon = computed(() => getCardBrandIcon(this.paymentMethod()));
// Show submit buttons only when component is managing its own form (no external form provided)
protected readonly showFormButtons = computed(() => this.externalFormGroup() === null);
private readonly billingClient = inject(SubscriberBillingClient);
private readonly i18nService = inject(I18nService);
private readonly toastService = inject(ToastService);
private readonly logService = inject(LogService);
constructor() {
// Use external form group if provided, otherwise create our own
this.formGroup = this.externalFormGroup() ?? EnterPaymentMethodComponent.getFormGroup();
}
/**
* Initiates the payment method change process by displaying the inline form.
*/
protected changePaymentMethod = async (): Promise<void> => {
this.isChangingPayment.set(true);
};
/**
* Public method to get tokenized payment method data.
* Use this when parent component handles submission.
* Parent is responsible for handling billing address separately.
* @returns Promise with tokenized payment method
*/
async getTokenizedPaymentMethod(): Promise<any> {
if (!this.formGroup.valid) {
this.formGroup.markAllAsTouched();
throw new Error("Form is invalid");
}
const component = this.enterPaymentMethodComponent();
if (!component) {
throw new Error("Payment method component not found");
}
const paymentMethod = await component.tokenize();
if (!paymentMethod) {
throw new Error("Failed to tokenize payment method");
}
return paymentMethod;
}
/**
* Validates the form and returns whether it's ready for submission.
* Used when parent component handles submission to determine button state.
*/
isFormValid(): boolean {
const enterPaymentMethodComponent = this.enterPaymentMethodComponent();
if (enterPaymentMethodComponent) {
return this.enterPaymentMethodComponent()!.validate();
}
return false;
}
/**
* Public method to reset the form and exit edit mode.
* Use this after parent successfully handles the update.
*/
resetForm(): void {
this.formGroup.reset();
this.isChangingPayment.set(false);
}
/**
* Submits the payment method update form.
* Validates the form, tokenizes the payment method, and sends the update request.
*/
protected submit = async (): Promise<void> => {
try {
const paymentMethod = await this.getTokenizedPaymentMethod();
const billingAddress =
this.formGroup.value.type !== TokenizablePaymentMethods.payPal
? this.formGroup.controls.billingAddress.getRawValue()
: null;
await this.handlePaymentMethodUpdate(paymentMethod, billingAddress);
} catch (error) {
this.logService.error("Error submitting payment method update:", error);
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("paymentMethodUpdateError"),
});
throw error;
}
};
/**
* Handles the payment method update API call and result processing.
*/
private async handlePaymentMethodUpdate(paymentMethod: any, billingAddress: any): Promise<void> {
const result = await this.billingClient.updatePaymentMethod(
this.subscriber(),
paymentMethod,
billingAddress,
);
switch (result.type) {
case "success": {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("paymentMethodUpdated"),
});
this.updated.emit(result.value);
this.resetForm();
break;
}
case "error": {
this.logService.error("Error submitting payment method update:", result);
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("paymentMethodUpdateError"),
});
break;
}
}
}
/**
* Cancels the inline editing and resets the form.
*/
protected cancel = (): void => {
this.resetForm();
};
}