mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-27925] Refactor StripeService to allow more than one instance (#17467)
* Refactor StripeService to allow more than one instance per scope * Fix linting issue * Claude's feedback
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
import { PlanType, ProductTierType, ProductType } from "@bitwarden/common/billing/enums";
|
||||
@@ -19,7 +19,7 @@ import { SharedModule } from "../../shared";
|
||||
templateUrl: "create-organization.component.html",
|
||||
imports: [SharedModule, OrganizationPlansComponent, HeaderModule],
|
||||
})
|
||||
export class CreateOrganizationComponent implements OnInit {
|
||||
export class CreateOrganizationComponent implements OnInit, OnDestroy {
|
||||
protected secretsManager = false;
|
||||
protected plan: PlanType = PlanType.Free;
|
||||
protected productTier: ProductTierType = ProductTierType.Free;
|
||||
@@ -29,6 +29,8 @@ export class CreateOrganizationComponent implements OnInit {
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const milestone3FeatureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM26462_Milestone_3,
|
||||
@@ -37,7 +39,7 @@ export class CreateOrganizationComponent implements OnInit {
|
||||
? PlanType.FamiliesAnnually
|
||||
: PlanType.FamiliesAnnually2025;
|
||||
|
||||
this.route.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((qParams) => {
|
||||
this.route.queryParams.pipe(first(), takeUntil(this.destroy$)).subscribe((qParams) => {
|
||||
if (qParams.plan === "families" || qParams.productTier == ProductTierType.Families) {
|
||||
this.plan = familyPlan;
|
||||
this.productTier = ProductTierType.Families;
|
||||
@@ -61,4 +63,9 @@ export class CreateOrganizationComponent implements OnInit {
|
||||
this.secretsManager = qParams.product == ProductType.SecretsManager;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { map, Observable, of, startWith, Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { PopoverModule, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
@@ -34,18 +35,17 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
}>;
|
||||
}>;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-enter-payment-method",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@let showBillingDetails = includeBillingAddress && selected !== "payPal";
|
||||
<form [formGroup]="group">
|
||||
@let showBillingDetails = includeBillingAddress() && selected !== "payPal";
|
||||
<form [formGroup]="group()">
|
||||
@if (showBillingDetails) {
|
||||
<h5 bitTypography="h5">{{ "paymentMethod" | i18n }}</h5>
|
||||
}
|
||||
<div class="tw-mb-4 tw-text-lg">
|
||||
<bit-radio-group [formControl]="group.controls.type">
|
||||
<bit-radio-group [formControl]="group().controls.type">
|
||||
<bit-radio-button id="card-payment-method" [value]="'card'">
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-credit-card" aria-hidden="true"></i>
|
||||
@@ -60,7 +60,7 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
}
|
||||
@if (showPayPal) {
|
||||
@if (showPayPal()) {
|
||||
<bit-radio-button id="paypal-payment-method" [value]="'payPal'">
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-paypal" aria-hidden="true"></i>
|
||||
@@ -68,7 +68,7 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
</bit-label>
|
||||
</bit-radio-button>
|
||||
}
|
||||
@if (showAccountCredit) {
|
||||
@if (showAccountCredit()) {
|
||||
<bit-radio-button id="credit-payment-method" [value]="'accountCredit'">
|
||||
<bit-label>
|
||||
<i class="bwi bwi-fw bwi-dollar" aria-hidden="true"></i>
|
||||
@@ -82,10 +82,10 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
@case ("card") {
|
||||
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4">
|
||||
<div class="tw-col-span-1">
|
||||
<app-payment-label for="stripe-card-number" required>
|
||||
<app-payment-label [for]="'stripe-card-number-' + instanceId" required>
|
||||
{{ "cardNumberLabel" | i18n }}
|
||||
</app-payment-label>
|
||||
<div id="stripe-card-number" class="tw-stripe-form-control"></div>
|
||||
<div [id]="'stripe-card-number-' + instanceId" class="tw-stripe-form-control"></div>
|
||||
</div>
|
||||
<div class="tw-col-span-1 tw-flex tw-items-end">
|
||||
<img
|
||||
@@ -95,13 +95,13 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
/>
|
||||
</div>
|
||||
<div class="tw-col-span-1">
|
||||
<app-payment-label for="stripe-card-expiry" required>
|
||||
<app-payment-label [for]="'stripe-card-expiry-' + instanceId" required>
|
||||
{{ "expiration" | i18n }}
|
||||
</app-payment-label>
|
||||
<div id="stripe-card-expiry" class="tw-stripe-form-control"></div>
|
||||
<div [id]="'stripe-card-expiry-' + instanceId" class="tw-stripe-form-control"></div>
|
||||
</div>
|
||||
<div class="tw-col-span-1">
|
||||
<app-payment-label for="stripe-card-cvc" required>
|
||||
<app-payment-label [for]="'stripe-card-cvc-' + instanceId" required>
|
||||
{{ "securityCodeSlashCVV" | i18n }}
|
||||
<button
|
||||
[bitPopoverTriggerFor]="cardSecurityCodePopover"
|
||||
@@ -115,7 +115,7 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
<p class="tw-mb-0">{{ "cardSecurityCodeDescription" | i18n }}</p>
|
||||
</bit-popover>
|
||||
</app-payment-label>
|
||||
<div id="stripe-card-cvc" class="tw-stripe-form-control"></div>
|
||||
<div [id]="'stripe-card-cvc-' + instanceId" class="tw-stripe-form-control"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -131,7 +131,7 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
bitInput
|
||||
id="routingNumber"
|
||||
type="text"
|
||||
[formControl]="group.controls.bankAccount.controls.routingNumber"
|
||||
[formControl]="group().controls.bankAccount.controls.routingNumber"
|
||||
required
|
||||
/>
|
||||
</bit-form-field>
|
||||
@@ -141,7 +141,7 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
bitInput
|
||||
id="accountNumber"
|
||||
type="text"
|
||||
[formControl]="group.controls.bankAccount.controls.accountNumber"
|
||||
[formControl]="group().controls.bankAccount.controls.accountNumber"
|
||||
required
|
||||
/>
|
||||
</bit-form-field>
|
||||
@@ -151,7 +151,7 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
id="accountHolderName"
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.bankAccount.controls.accountHolderName"
|
||||
[formControl]="group().controls.bankAccount.controls.accountHolderName"
|
||||
required
|
||||
/>
|
||||
</bit-form-field>
|
||||
@@ -159,7 +159,7 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
<bit-label>{{ "bankAccountType" | i18n }}</bit-label>
|
||||
<bit-select
|
||||
id="accountHolderType"
|
||||
[formControl]="group.controls.bankAccount.controls.accountHolderType"
|
||||
[formControl]="group().controls.bankAccount.controls.accountHolderType"
|
||||
required
|
||||
>
|
||||
<bit-option [value]="''" label="-- {{ 'select' | i18n }} --"></bit-option>
|
||||
@@ -186,7 +186,7 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
}
|
||||
@case ("accountCredit") {
|
||||
<ng-container>
|
||||
@if (hasEnoughAccountCredit) {
|
||||
@if (hasEnoughAccountCredit()) {
|
||||
<bit-callout type="info">
|
||||
{{ "makeSureEnoughCredit" | i18n }}
|
||||
</bit-callout>
|
||||
@@ -204,7 +204,7 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "country" | i18n }}</bit-label>
|
||||
<bit-select [formControl]="group.controls.billingAddress.controls.country">
|
||||
<bit-select [formControl]="group().controls.billingAddress.controls.country">
|
||||
@for (selectableCountry of selectableCountries; track selectableCountry.value) {
|
||||
<bit-option
|
||||
[value]="selectableCountry.value"
|
||||
@@ -221,7 +221,7 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
[formControl]="group.controls.billingAddress.controls.postalCode"
|
||||
[formControl]="group().controls.billingAddress.controls.postalCode"
|
||||
autocomplete="postal-code"
|
||||
/>
|
||||
</bit-form-field>
|
||||
@@ -233,26 +233,15 @@ type PaymentMethodFormGroup = FormGroup<{
|
||||
standalone: true,
|
||||
imports: [BillingServicesModule, PaymentLabelComponent, PopoverModule, SharedModule],
|
||||
})
|
||||
export class EnterPaymentMethodComponent implements OnInit {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({ required: true }) group!: PaymentMethodFormGroup;
|
||||
export class EnterPaymentMethodComponent implements OnInit, OnDestroy {
|
||||
protected readonly instanceId = Utils.newGuid();
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() private showBankAccount = true;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() showPayPal = true;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() showAccountCredit = false;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() hasEnoughAccountCredit = true;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() includeBillingAddress = false;
|
||||
readonly group = input.required<PaymentMethodFormGroup>();
|
||||
protected readonly showBankAccount = input(true);
|
||||
readonly showPayPal = input(true);
|
||||
readonly showAccountCredit = input(false);
|
||||
readonly hasEnoughAccountCredit = input(true);
|
||||
readonly includeBillingAddress = input(false);
|
||||
|
||||
protected showBankAccount$!: Observable<boolean>;
|
||||
protected selectableCountries = selectableCountries;
|
||||
@@ -269,57 +258,62 @@ export class EnterPaymentMethodComponent implements OnInit {
|
||||
|
||||
ngOnInit() {
|
||||
this.stripeService.loadStripe(
|
||||
this.instanceId,
|
||||
{
|
||||
cardNumber: "#stripe-card-number",
|
||||
cardExpiry: "#stripe-card-expiry",
|
||||
cardCvc: "#stripe-card-cvc",
|
||||
cardNumber: `#stripe-card-number-${this.instanceId}`,
|
||||
cardExpiry: `#stripe-card-expiry-${this.instanceId}`,
|
||||
cardCvc: `#stripe-card-cvc-${this.instanceId}`,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
if (this.showPayPal) {
|
||||
if (this.showPayPal()) {
|
||||
this.braintreeService.loadBraintree("#braintree-container", false);
|
||||
}
|
||||
|
||||
if (!this.includeBillingAddress) {
|
||||
this.showBankAccount$ = of(this.showBankAccount);
|
||||
this.group.controls.billingAddress.disable();
|
||||
if (!this.includeBillingAddress()) {
|
||||
this.showBankAccount$ = of(this.showBankAccount());
|
||||
this.group().controls.billingAddress.disable();
|
||||
} else {
|
||||
this.group.controls.billingAddress.patchValue({
|
||||
this.group().controls.billingAddress.patchValue({
|
||||
country: "US",
|
||||
});
|
||||
this.showBankAccount$ = this.group.controls.billingAddress.controls.country.valueChanges.pipe(
|
||||
startWith(this.group.controls.billingAddress.controls.country.value),
|
||||
map((country) => this.showBankAccount && country === "US"),
|
||||
);
|
||||
this.showBankAccount$ =
|
||||
this.group().controls.billingAddress.controls.country.valueChanges.pipe(
|
||||
startWith(this.group().controls.billingAddress.controls.country.value),
|
||||
map((country) => this.showBankAccount() && country === "US"),
|
||||
);
|
||||
}
|
||||
|
||||
this.group.controls.type.valueChanges
|
||||
.pipe(startWith(this.group.controls.type.value), takeUntil(this.destroy$))
|
||||
this.group()
|
||||
.controls.type.valueChanges.pipe(
|
||||
startWith(this.group().controls.type.value),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe((selected) => {
|
||||
if (selected === "bankAccount") {
|
||||
this.group.controls.bankAccount.enable();
|
||||
if (this.includeBillingAddress) {
|
||||
this.group.controls.billingAddress.enable();
|
||||
this.group().controls.bankAccount.enable();
|
||||
if (this.includeBillingAddress()) {
|
||||
this.group().controls.billingAddress.enable();
|
||||
}
|
||||
} else {
|
||||
switch (selected) {
|
||||
case "card": {
|
||||
this.stripeService.mountElements();
|
||||
if (this.includeBillingAddress) {
|
||||
this.group.controls.billingAddress.enable();
|
||||
this.stripeService.mountElements(this.instanceId);
|
||||
if (this.includeBillingAddress()) {
|
||||
this.group().controls.billingAddress.enable();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "payPal": {
|
||||
this.braintreeService.createDropin();
|
||||
if (this.includeBillingAddress) {
|
||||
this.group.controls.billingAddress.disable();
|
||||
if (this.includeBillingAddress()) {
|
||||
this.group().controls.billingAddress.disable();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.group.controls.bankAccount.disable();
|
||||
this.group().controls.bankAccount.disable();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -330,22 +324,28 @@ export class EnterPaymentMethodComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.stripeService.unloadStripe(this.instanceId);
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
select = (paymentMethod: PaymentMethodOption) =>
|
||||
this.group.controls.type.patchValue(paymentMethod);
|
||||
this.group().controls.type.patchValue(paymentMethod);
|
||||
|
||||
tokenize = async (): Promise<TokenizedPaymentMethod | null> => {
|
||||
const exchange = async (paymentMethod: TokenizablePaymentMethod) => {
|
||||
switch (paymentMethod) {
|
||||
case "bankAccount": {
|
||||
this.group.controls.bankAccount.markAllAsTouched();
|
||||
if (!this.group.controls.bankAccount.valid) {
|
||||
this.group().controls.bankAccount.markAllAsTouched();
|
||||
if (!this.group().controls.bankAccount.valid) {
|
||||
throw new Error("Attempted to tokenize invalid bank account information.");
|
||||
}
|
||||
|
||||
const bankAccount = this.group.controls.bankAccount.getRawValue();
|
||||
const bankAccount = this.group().controls.bankAccount.getRawValue();
|
||||
const clientSecret = await this.stripeService.createSetupIntent("bankAccount");
|
||||
const billingDetails = this.group.controls.billingAddress.enabled
|
||||
? this.group.controls.billingAddress.getRawValue()
|
||||
const billingDetails = this.group().controls.billingAddress.enabled
|
||||
? this.group().controls.billingAddress.getRawValue()
|
||||
: undefined;
|
||||
return await this.stripeService.setupBankAccountPaymentMethod(
|
||||
clientSecret,
|
||||
@@ -355,10 +355,14 @@ export class EnterPaymentMethodComponent implements OnInit {
|
||||
}
|
||||
case "card": {
|
||||
const clientSecret = await this.stripeService.createSetupIntent("card");
|
||||
const billingDetails = this.group.controls.billingAddress.enabled
|
||||
? this.group.controls.billingAddress.getRawValue()
|
||||
const billingDetails = this.group().controls.billingAddress.enabled
|
||||
? this.group().controls.billingAddress.getRawValue()
|
||||
: undefined;
|
||||
return this.stripeService.setupCardPaymentMethod(clientSecret, billingDetails);
|
||||
return this.stripeService.setupCardPaymentMethod(
|
||||
this.instanceId,
|
||||
clientSecret,
|
||||
billingDetails,
|
||||
);
|
||||
}
|
||||
case "payPal": {
|
||||
return this.braintreeService.requestPaymentMethod();
|
||||
@@ -410,15 +414,15 @@ export class EnterPaymentMethodComponent implements OnInit {
|
||||
|
||||
validate = (): boolean => {
|
||||
if (this.selected === "bankAccount") {
|
||||
this.group.controls.bankAccount.markAllAsTouched();
|
||||
return this.group.controls.bankAccount.valid;
|
||||
this.group().controls.bankAccount.markAllAsTouched();
|
||||
return this.group().controls.bankAccount.valid;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
get selected(): PaymentMethodOption {
|
||||
return this.group.value.type!;
|
||||
return this.group().value.type!;
|
||||
}
|
||||
|
||||
static getFormGroup = (): PaymentMethodFormGroup =>
|
||||
|
||||
797
apps/web/src/app/billing/services/stripe.service.spec.ts
Normal file
797
apps/web/src/app/billing/services/stripe.service.spec.ts
Normal file
@@ -0,0 +1,797 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { BankAccount } from "@bitwarden/common/billing/models/domain";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
import { StripeService } from "./stripe.service";
|
||||
|
||||
// Extend Window interface to include Stripe
|
||||
declare global {
|
||||
interface Window {
|
||||
Stripe: any;
|
||||
}
|
||||
}
|
||||
|
||||
describe("StripeService", () => {
|
||||
let service: StripeService;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
// Stripe SDK mocks
|
||||
let mockStripeInstance: any;
|
||||
let mockElements: any;
|
||||
let mockCardNumber: any;
|
||||
let mockCardExpiry: any;
|
||||
let mockCardCvc: any;
|
||||
|
||||
// DOM mocks
|
||||
let mockScript: HTMLScriptElement;
|
||||
let mockIframe: HTMLIFrameElement;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Setup service dependency mocks
|
||||
apiService = mock<ApiService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
// Setup Stripe element mocks
|
||||
mockCardNumber = {
|
||||
mount: jest.fn(),
|
||||
unmount: jest.fn(),
|
||||
};
|
||||
mockCardExpiry = {
|
||||
mount: jest.fn(),
|
||||
unmount: jest.fn(),
|
||||
};
|
||||
mockCardCvc = {
|
||||
mount: jest.fn(),
|
||||
unmount: jest.fn(),
|
||||
};
|
||||
|
||||
// Setup Stripe Elements mock
|
||||
mockElements = {
|
||||
create: jest.fn((type: string) => {
|
||||
switch (type) {
|
||||
case "cardNumber":
|
||||
return mockCardNumber;
|
||||
case "cardExpiry":
|
||||
return mockCardExpiry;
|
||||
case "cardCvc":
|
||||
return mockCardCvc;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
getElement: jest.fn((type: string) => {
|
||||
switch (type) {
|
||||
case "cardNumber":
|
||||
return mockCardNumber;
|
||||
case "cardExpiry":
|
||||
return mockCardExpiry;
|
||||
case "cardCvc":
|
||||
return mockCardCvc;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
// Setup Stripe instance mock
|
||||
mockStripeInstance = {
|
||||
elements: jest.fn(() => mockElements),
|
||||
confirmCardSetup: jest.fn(),
|
||||
confirmUsBankAccountSetup: jest.fn(),
|
||||
};
|
||||
|
||||
// Setup window.Stripe mock
|
||||
window.Stripe = jest.fn(() => mockStripeInstance);
|
||||
|
||||
// Setup DOM mocks
|
||||
mockScript = {
|
||||
id: "",
|
||||
src: "",
|
||||
onload: null,
|
||||
onerror: null,
|
||||
} as any;
|
||||
|
||||
mockIframe = {
|
||||
src: "https://js.stripe.com/v3/",
|
||||
remove: jest.fn(),
|
||||
} as any;
|
||||
|
||||
jest.spyOn(window.document, "createElement").mockReturnValue(mockScript);
|
||||
jest.spyOn(window.document, "getElementById").mockReturnValue(null);
|
||||
jest.spyOn(window.document.head, "appendChild").mockReturnValue(mockScript);
|
||||
jest.spyOn(window.document.head, "removeChild").mockImplementation(() => mockScript);
|
||||
jest.spyOn(window.document, "querySelectorAll").mockReturnValue([mockIframe] as any);
|
||||
|
||||
// Mock getComputedStyle
|
||||
jest.spyOn(window, "getComputedStyle").mockReturnValue({
|
||||
getPropertyValue: (prop: string) => {
|
||||
const props: Record<string, string> = {
|
||||
"--color-text-main": "0, 0, 0",
|
||||
"--color-text-muted": "128, 128, 128",
|
||||
"--color-danger-600": "220, 38, 38",
|
||||
};
|
||||
return props[prop] || "";
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Create service instance
|
||||
service = new StripeService(apiService, logService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Helper function to trigger script load
|
||||
const triggerScriptLoad = () => {
|
||||
if (mockScript.onload) {
|
||||
mockScript.onload(new Event("load"));
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to advance timers and flush promises
|
||||
const advanceTimersAndFlush = async (ms: number) => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
describe("createSetupIntent", () => {
|
||||
it("should call API with correct path for card payment", async () => {
|
||||
apiService.send.mockResolvedValue("client_secret_card_123");
|
||||
|
||||
const result = await service.createSetupIntent("card");
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith("POST", "/setup-intent/card", null, true, true);
|
||||
expect(result).toBe("client_secret_card_123");
|
||||
});
|
||||
|
||||
it("should call API with correct path for bank account payment", async () => {
|
||||
apiService.send.mockResolvedValue("client_secret_bank_456");
|
||||
|
||||
const result = await service.createSetupIntent("bankAccount");
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/setup-intent/bank-account",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBe("client_secret_bank_456");
|
||||
});
|
||||
|
||||
it("should return client secret from API response", async () => {
|
||||
const expectedSecret = "seti_1234567890_secret_abcdefg";
|
||||
apiService.send.mockResolvedValue(expectedSecret);
|
||||
|
||||
const result = await service.createSetupIntent("card");
|
||||
|
||||
expect(result).toBe(expectedSecret);
|
||||
});
|
||||
|
||||
it("should propagate API errors", async () => {
|
||||
const error = new Error("API error");
|
||||
apiService.send.mockRejectedValue(error);
|
||||
|
||||
await expect(service.createSetupIntent("card")).rejects.toThrow("API error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadStripe - initial load", () => {
|
||||
const instanceId = "test-instance-1";
|
||||
const elementIds = {
|
||||
cardNumber: "#card-number",
|
||||
cardExpiry: "#card-expiry",
|
||||
cardCvc: "#card-cvc",
|
||||
};
|
||||
|
||||
it("should create script element with correct attributes", () => {
|
||||
service.loadStripe(instanceId, elementIds, false);
|
||||
|
||||
expect(window.document.createElement).toHaveBeenCalledWith("script");
|
||||
expect(mockScript.id).toBe("stripe-script");
|
||||
expect(mockScript.src).toBe("https://js.stripe.com/v3?advancedFraudSignals=false");
|
||||
});
|
||||
|
||||
it("should append script to document head", () => {
|
||||
service.loadStripe(instanceId, elementIds, false);
|
||||
|
||||
expect(window.document.head.appendChild).toHaveBeenCalledWith(mockScript);
|
||||
});
|
||||
|
||||
it("should initialize Stripe client on script load", async () => {
|
||||
service.loadStripe(instanceId, elementIds, false);
|
||||
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(0);
|
||||
|
||||
expect(window.Stripe).toHaveBeenCalledWith(process.env.STRIPE_KEY);
|
||||
});
|
||||
|
||||
it("should create Elements instance and store in Map", async () => {
|
||||
service.loadStripe(instanceId, elementIds, false);
|
||||
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(50);
|
||||
|
||||
expect(mockStripeInstance.elements).toHaveBeenCalled();
|
||||
expect(service["instances"].size).toBe(1);
|
||||
expect(service["instances"].get(instanceId)).toBeDefined();
|
||||
});
|
||||
|
||||
it("should increment instanceCount", async () => {
|
||||
service.loadStripe(instanceId, elementIds, false);
|
||||
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(50);
|
||||
|
||||
expect(service["instanceCount"]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadStripe - already loaded", () => {
|
||||
const instanceId1 = "instance-1";
|
||||
const instanceId2 = "instance-2";
|
||||
const elementIds = {
|
||||
cardNumber: "#card-number",
|
||||
cardExpiry: "#card-expiry",
|
||||
cardCvc: "#card-cvc",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Load first instance to initialize Stripe
|
||||
service.loadStripe(instanceId1, elementIds, false);
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(100);
|
||||
});
|
||||
|
||||
it("should not create new script if already loaded", () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
service.loadStripe(instanceId2, elementIds, false);
|
||||
|
||||
expect(window.document.createElement).not.toHaveBeenCalled();
|
||||
expect(window.document.head.appendChild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should immediately initialize instance when script loaded", async () => {
|
||||
service.loadStripe(instanceId2, elementIds, false);
|
||||
await advanceTimersAndFlush(50);
|
||||
|
||||
expect(service["instances"].size).toBe(2);
|
||||
expect(service["instances"].get(instanceId2)).toBeDefined();
|
||||
});
|
||||
|
||||
it("should increment instanceCount correctly", async () => {
|
||||
expect(service["instanceCount"]).toBe(1);
|
||||
|
||||
service.loadStripe(instanceId2, elementIds, false);
|
||||
await advanceTimersAndFlush(50);
|
||||
|
||||
expect(service["instanceCount"]).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadStripe - concurrent calls", () => {
|
||||
const elementIds = {
|
||||
cardNumber: "#card-number",
|
||||
cardExpiry: "#card-expiry",
|
||||
cardCvc: "#card-cvc",
|
||||
};
|
||||
|
||||
it("should handle multiple loadStripe calls sequentially", async () => {
|
||||
// Test practical scenario: load instances one after another
|
||||
service.loadStripe("instance-1", elementIds, false);
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
service.loadStripe("instance-2", elementIds, false);
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
service.loadStripe("instance-3", elementIds, false);
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
// All instances should be initialized
|
||||
expect(service["instances"].size).toBe(3);
|
||||
expect(service["instanceCount"]).toBe(3);
|
||||
expect(service["instances"].get("instance-1")).toBeDefined();
|
||||
expect(service["instances"].get("instance-2")).toBeDefined();
|
||||
expect(service["instances"].get("instance-3")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should share Stripe client across instances", async () => {
|
||||
// Load first instance
|
||||
service.loadStripe("instance-1", elementIds, false);
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
const stripeClientAfterFirst = service["stripe"];
|
||||
expect(stripeClientAfterFirst).toBeDefined();
|
||||
|
||||
// Load second instance
|
||||
service.loadStripe("instance-2", elementIds, false);
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
// Should reuse the same Stripe client
|
||||
expect(service["stripe"]).toBe(stripeClientAfterFirst);
|
||||
expect(service["instances"].size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mountElements - success path", () => {
|
||||
const instanceId = "mount-test-instance";
|
||||
const elementIds = {
|
||||
cardNumber: "#card-number-mount",
|
||||
cardExpiry: "#card-expiry-mount",
|
||||
cardCvc: "#card-cvc-mount",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
service.loadStripe(instanceId, elementIds, false);
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(100);
|
||||
});
|
||||
|
||||
it("should mount all three card elements to DOM", async () => {
|
||||
service.mountElements(instanceId);
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
expect(mockCardNumber.mount).toHaveBeenCalledWith("#card-number-mount");
|
||||
expect(mockCardExpiry.mount).toHaveBeenCalledWith("#card-expiry-mount");
|
||||
expect(mockCardCvc.mount).toHaveBeenCalledWith("#card-cvc-mount");
|
||||
});
|
||||
|
||||
it("should use correct element IDs from instance", async () => {
|
||||
const customIds = {
|
||||
cardNumber: "#custom-card",
|
||||
cardExpiry: "#custom-expiry",
|
||||
cardCvc: "#custom-cvc",
|
||||
};
|
||||
|
||||
service.loadStripe("custom-instance", customIds, false);
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
service.mountElements("custom-instance");
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
expect(mockCardNumber.mount).toHaveBeenCalledWith("#custom-card");
|
||||
expect(mockCardExpiry.mount).toHaveBeenCalledWith("#custom-expiry");
|
||||
expect(mockCardCvc.mount).toHaveBeenCalledWith("#custom-cvc");
|
||||
});
|
||||
|
||||
it("should handle autoMount flag correctly", async () => {
|
||||
const autoMountId = "auto-mount-instance";
|
||||
jest.clearAllMocks();
|
||||
|
||||
service.loadStripe(autoMountId, elementIds, true);
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(150);
|
||||
|
||||
// Should auto-mount without explicit call
|
||||
expect(mockCardNumber.mount).toHaveBeenCalled();
|
||||
expect(mockCardExpiry.mount).toHaveBeenCalled();
|
||||
expect(mockCardCvc.mount).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mountElements - retry logic", () => {
|
||||
const elementIds = {
|
||||
cardNumber: "#card-number",
|
||||
cardExpiry: "#card-expiry",
|
||||
cardCvc: "#card-cvc",
|
||||
};
|
||||
|
||||
it("should retry if instance not found", async () => {
|
||||
service.mountElements("non-existent-instance");
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
expect(logService.warning).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Stripe instance non-existent-instance not found"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should log error after 10 failed attempts", async () => {
|
||||
service.mountElements("non-existent-instance");
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await advanceTimersAndFlush(100);
|
||||
}
|
||||
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("not found after 10 attempts"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should retry if elements not ready", async () => {
|
||||
const instanceId = "retry-elements-instance";
|
||||
service.loadStripe(instanceId, elementIds, false);
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
// Make elements temporarily unavailable
|
||||
mockElements.getElement.mockReturnValueOnce(null);
|
||||
mockElements.getElement.mockReturnValueOnce(null);
|
||||
mockElements.getElement.mockReturnValueOnce(null);
|
||||
|
||||
service.mountElements(instanceId);
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
expect(logService.warning).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Some Stripe card elements"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupCardPaymentMethod", () => {
|
||||
const instanceId = "card-setup-instance";
|
||||
const clientSecret = "seti_card_secret_123";
|
||||
const elementIds = {
|
||||
cardNumber: "#card-number",
|
||||
cardExpiry: "#card-expiry",
|
||||
cardCvc: "#card-cvc",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
service.loadStripe(instanceId, elementIds, false);
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(100);
|
||||
});
|
||||
|
||||
it("should call Stripe confirmCardSetup with correct parameters", async () => {
|
||||
mockStripeInstance.confirmCardSetup.mockResolvedValue({
|
||||
setupIntent: { status: "succeeded", payment_method: "pm_card_123" },
|
||||
});
|
||||
|
||||
await service.setupCardPaymentMethod(instanceId, clientSecret);
|
||||
|
||||
expect(mockStripeInstance.confirmCardSetup).toHaveBeenCalledWith(clientSecret, {
|
||||
payment_method: {
|
||||
card: mockCardNumber,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should include billing details when provided", async () => {
|
||||
mockStripeInstance.confirmCardSetup.mockResolvedValue({
|
||||
setupIntent: { status: "succeeded", payment_method: "pm_card_123" },
|
||||
});
|
||||
|
||||
const billingDetails = { country: "US", postalCode: "12345" };
|
||||
await service.setupCardPaymentMethod(instanceId, clientSecret, billingDetails);
|
||||
|
||||
expect(mockStripeInstance.confirmCardSetup).toHaveBeenCalledWith(clientSecret, {
|
||||
payment_method: {
|
||||
card: mockCardNumber,
|
||||
billing_details: {
|
||||
address: {
|
||||
country: "US",
|
||||
postal_code: "12345",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error if instance not found", async () => {
|
||||
await expect(service.setupCardPaymentMethod("non-existent", clientSecret)).rejects.toThrow(
|
||||
"Payment method initialization failed. Please try again.",
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Stripe instance non-existent not found"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error if setup fails", async () => {
|
||||
const error = { message: "Card declined" };
|
||||
mockStripeInstance.confirmCardSetup.mockResolvedValue({ error });
|
||||
|
||||
await expect(service.setupCardPaymentMethod(instanceId, clientSecret)).rejects.toEqual(error);
|
||||
expect(logService.error).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it("should throw error if status is not succeeded", async () => {
|
||||
const error = { message: "Invalid status" };
|
||||
mockStripeInstance.confirmCardSetup.mockResolvedValue({
|
||||
setupIntent: { status: "requires_action" },
|
||||
error,
|
||||
});
|
||||
|
||||
await expect(service.setupCardPaymentMethod(instanceId, clientSecret)).rejects.toEqual(error);
|
||||
});
|
||||
|
||||
it("should return payment method ID on success", async () => {
|
||||
mockStripeInstance.confirmCardSetup.mockResolvedValue({
|
||||
setupIntent: { status: "succeeded", payment_method: "pm_card_success_123" },
|
||||
});
|
||||
|
||||
const result = await service.setupCardPaymentMethod(instanceId, clientSecret);
|
||||
|
||||
expect(result).toBe("pm_card_success_123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupBankAccountPaymentMethod", () => {
|
||||
const clientSecret = "seti_bank_secret_456";
|
||||
const bankAccount: BankAccount = {
|
||||
accountHolderName: "John Doe",
|
||||
routingNumber: "110000000",
|
||||
accountNumber: "000123456789",
|
||||
accountHolderType: "individual",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Initialize Stripe instance for bank account tests
|
||||
service.loadStripe(
|
||||
"bank-test-instance",
|
||||
{
|
||||
cardNumber: "#card",
|
||||
cardExpiry: "#expiry",
|
||||
cardCvc: "#cvc",
|
||||
},
|
||||
false,
|
||||
);
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(100);
|
||||
});
|
||||
|
||||
it("should call Stripe confirmUsBankAccountSetup with bank details", async () => {
|
||||
mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({
|
||||
setupIntent: { status: "requires_action", payment_method: "pm_bank_123" },
|
||||
});
|
||||
|
||||
await service.setupBankAccountPaymentMethod(clientSecret, bankAccount);
|
||||
|
||||
expect(mockStripeInstance.confirmUsBankAccountSetup).toHaveBeenCalledWith(clientSecret, {
|
||||
payment_method: {
|
||||
us_bank_account: {
|
||||
routing_number: "110000000",
|
||||
account_number: "000123456789",
|
||||
account_holder_type: "individual",
|
||||
},
|
||||
billing_details: {
|
||||
name: "John Doe",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should include billing address when provided", async () => {
|
||||
mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({
|
||||
setupIntent: { status: "requires_action", payment_method: "pm_bank_123" },
|
||||
});
|
||||
|
||||
const billingDetails = { country: "US", postalCode: "90210" };
|
||||
await service.setupBankAccountPaymentMethod(clientSecret, bankAccount, billingDetails);
|
||||
|
||||
expect(mockStripeInstance.confirmUsBankAccountSetup).toHaveBeenCalledWith(clientSecret, {
|
||||
payment_method: {
|
||||
us_bank_account: {
|
||||
routing_number: "110000000",
|
||||
account_number: "000123456789",
|
||||
account_holder_type: "individual",
|
||||
},
|
||||
billing_details: {
|
||||
name: "John Doe",
|
||||
address: {
|
||||
country: "US",
|
||||
postal_code: "90210",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit billing address when not provided", async () => {
|
||||
mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({
|
||||
setupIntent: { status: "requires_action", payment_method: "pm_bank_123" },
|
||||
});
|
||||
|
||||
await service.setupBankAccountPaymentMethod(clientSecret, bankAccount);
|
||||
|
||||
const call = mockStripeInstance.confirmUsBankAccountSetup.mock.calls[0][1];
|
||||
expect(call.payment_method.billing_details.address).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should validate status is requires_action", async () => {
|
||||
const error = { message: "Invalid status" };
|
||||
mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({
|
||||
setupIntent: { status: "succeeded" },
|
||||
error,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.setupBankAccountPaymentMethod(clientSecret, bankAccount),
|
||||
).rejects.toEqual(error);
|
||||
});
|
||||
|
||||
it("should return payment method ID on success", async () => {
|
||||
mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({
|
||||
setupIntent: { status: "requires_action", payment_method: "pm_bank_success_456" },
|
||||
});
|
||||
|
||||
const result = await service.setupBankAccountPaymentMethod(clientSecret, bankAccount);
|
||||
|
||||
expect(result).toBe("pm_bank_success_456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unloadStripe - single instance", () => {
|
||||
const instanceId = "unload-test-instance";
|
||||
const elementIds = {
|
||||
cardNumber: "#card-number",
|
||||
cardExpiry: "#card-expiry",
|
||||
cardCvc: "#card-cvc",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
service.loadStripe(instanceId, elementIds, false);
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(100);
|
||||
});
|
||||
|
||||
it("should unmount all card elements", () => {
|
||||
service.unloadStripe(instanceId);
|
||||
|
||||
expect(mockCardNumber.unmount).toHaveBeenCalled();
|
||||
expect(mockCardExpiry.unmount).toHaveBeenCalled();
|
||||
expect(mockCardCvc.unmount).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should remove instance from Map", () => {
|
||||
expect(service["instances"].has(instanceId)).toBe(true);
|
||||
|
||||
service.unloadStripe(instanceId);
|
||||
|
||||
expect(service["instances"].has(instanceId)).toBe(false);
|
||||
});
|
||||
|
||||
it("should decrement instanceCount", () => {
|
||||
expect(service["instanceCount"]).toBe(1);
|
||||
|
||||
service.unloadStripe(instanceId);
|
||||
|
||||
expect(service["instanceCount"]).toBe(0);
|
||||
});
|
||||
|
||||
it("should remove script when last instance unloaded", () => {
|
||||
jest.spyOn(window.document, "getElementById").mockReturnValue(mockScript);
|
||||
|
||||
service.unloadStripe(instanceId);
|
||||
|
||||
expect(window.document.head.removeChild).toHaveBeenCalledWith(mockScript);
|
||||
});
|
||||
|
||||
it("should remove Stripe iframes after cleanup delay", async () => {
|
||||
service.unloadStripe(instanceId);
|
||||
|
||||
await advanceTimersAndFlush(500);
|
||||
|
||||
expect(window.document.querySelectorAll).toHaveBeenCalledWith("iframe");
|
||||
expect(mockIframe.remove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unloadStripe - multiple instances", () => {
|
||||
const elementIds = {
|
||||
cardNumber: "#card-number",
|
||||
cardExpiry: "#card-expiry",
|
||||
cardCvc: "#card-cvc",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Load first instance
|
||||
service.loadStripe("instance-1", elementIds, false);
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
// Load second instance (script already loaded)
|
||||
service.loadStripe("instance-2", elementIds, false);
|
||||
await advanceTimersAndFlush(100);
|
||||
});
|
||||
|
||||
it("should not remove script when other instances exist", () => {
|
||||
expect(service["instanceCount"]).toBe(2);
|
||||
|
||||
service.unloadStripe("instance-1");
|
||||
|
||||
expect(service["instanceCount"]).toBe(1);
|
||||
expect(window.document.head.removeChild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should only cleanup specific instance", () => {
|
||||
service.unloadStripe("instance-1");
|
||||
|
||||
expect(service["instances"].has("instance-1")).toBe(false);
|
||||
expect(service["instances"].has("instance-2")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle reference counting correctly", () => {
|
||||
expect(service["instanceCount"]).toBe(2);
|
||||
|
||||
service.unloadStripe("instance-1");
|
||||
expect(service["instanceCount"]).toBe(1);
|
||||
|
||||
service.unloadStripe("instance-2");
|
||||
expect(service["instanceCount"]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unloadStripe - edge cases", () => {
|
||||
it("should handle unload of non-existent instance gracefully", () => {
|
||||
expect(() => service.unloadStripe("non-existent")).not.toThrow();
|
||||
expect(service["instanceCount"]).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle duplicate unload calls", async () => {
|
||||
const instanceId = "duplicate-unload";
|
||||
const elementIds = {
|
||||
cardNumber: "#card-number",
|
||||
cardExpiry: "#card-expiry",
|
||||
cardCvc: "#card-cvc",
|
||||
};
|
||||
|
||||
service.loadStripe(instanceId, elementIds, false);
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
service.unloadStripe(instanceId);
|
||||
expect(service["instanceCount"]).toBe(0);
|
||||
|
||||
service.unloadStripe(instanceId);
|
||||
expect(service["instanceCount"]).toBe(0); // Should not go negative
|
||||
});
|
||||
|
||||
it("should catch and log element unmount errors", async () => {
|
||||
const instanceId = "error-unmount";
|
||||
const elementIds = {
|
||||
cardNumber: "#card-number",
|
||||
cardExpiry: "#card-expiry",
|
||||
cardCvc: "#card-cvc",
|
||||
};
|
||||
|
||||
service.loadStripe(instanceId, elementIds, false);
|
||||
triggerScriptLoad();
|
||||
await advanceTimersAndFlush(100);
|
||||
|
||||
const unmountError = new Error("Unmount failed");
|
||||
mockCardNumber.unmount.mockImplementation(() => {
|
||||
throw unmountError;
|
||||
});
|
||||
|
||||
service.unloadStripe(instanceId);
|
||||
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Error unmounting Stripe elements"),
|
||||
unmountError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("element styling", () => {
|
||||
it("should apply correct CSS custom properties", () => {
|
||||
const options = service["getElementOptions"]("cardNumber");
|
||||
|
||||
expect(options.style.base.color).toBe("rgb(0, 0, 0)");
|
||||
expect(options.style.base["::placeholder"].color).toBe("rgb(128, 128, 128)");
|
||||
expect(options.style.invalid.color).toBe("rgb(0, 0, 0)");
|
||||
expect(options.style.invalid.borderColor).toBe("rgb(220, 38, 38)");
|
||||
});
|
||||
|
||||
it("should remove placeholder for cardNumber and cardCvc", () => {
|
||||
const cardNumberOptions = service["getElementOptions"]("cardNumber");
|
||||
const cardCvcOptions = service["getElementOptions"]("cardCvc");
|
||||
const cardExpiryOptions = service["getElementOptions"]("cardExpiry");
|
||||
|
||||
expect(cardNumberOptions.placeholder).toBe("");
|
||||
expect(cardCvcOptions.placeholder).toBe("");
|
||||
expect(cardExpiryOptions.placeholder).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,6 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
|
||||
import { BankAccountPaymentMethod, CardPaymentMethod } from "../payment/types";
|
||||
|
||||
import { BillingServicesModule } from "./billing-services.module";
|
||||
|
||||
type SetupBankAccountRequest = {
|
||||
payment_method: {
|
||||
us_bank_account: {
|
||||
@@ -39,15 +37,21 @@ type SetupCardRequest = {
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: BillingServicesModule })
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class StripeService {
|
||||
private stripe: any;
|
||||
private elements: any;
|
||||
private elementIds: {
|
||||
cardNumber: string;
|
||||
cardExpiry: string;
|
||||
cardCvc: string;
|
||||
};
|
||||
// Shared/Global - One Stripe client for entire application
|
||||
private stripe: any = null;
|
||||
private stripeScriptLoaded = false;
|
||||
private instanceCount = 0;
|
||||
|
||||
// Per-Instance - Isolated Elements for each component
|
||||
private instances = new Map<
|
||||
string,
|
||||
{
|
||||
elements: any;
|
||||
elementIds: { cardNumber: string; cardExpiry: string; cardCvc: string };
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
@@ -76,53 +80,121 @@ export class StripeService {
|
||||
* Loads [Stripe JS]{@link https://docs.stripe.com/js} in the <head> element of the current page and mounts
|
||||
* Stripe credit card [elements]{@link https://docs.stripe.com/js/elements_object/create} into the HTML elements with the provided element IDS.
|
||||
* We do this to avoid having to load the Stripe JS SDK on every page of the Web Vault given many pages contain sensitive information.
|
||||
* @param instanceId - Unique identifier for this component instance.
|
||||
* @param elementIds - The ID attributes of the HTML elements used to load the Stripe JS credit card elements.
|
||||
* @param autoMount - A flag indicating whether you want to immediately mount the Stripe credit card elements.
|
||||
*/
|
||||
loadStripe(
|
||||
instanceId: string,
|
||||
elementIds: { cardNumber: string; cardExpiry: string; cardCvc: string },
|
||||
autoMount: boolean,
|
||||
) {
|
||||
this.elementIds = elementIds;
|
||||
const script = window.document.createElement("script");
|
||||
script.id = "stripe-script";
|
||||
script.src = "https://js.stripe.com/v3?advancedFraudSignals=false";
|
||||
script.onload = async () => {
|
||||
const window$ = window as any;
|
||||
this.stripe = window$.Stripe(process.env.STRIPE_KEY);
|
||||
this.elements = this.stripe.elements();
|
||||
setTimeout(() => {
|
||||
this.elements.create("cardNumber", this.getElementOptions("cardNumber"));
|
||||
this.elements.create("cardExpiry", this.getElementOptions("cardExpiry"));
|
||||
this.elements.create("cardCvc", this.getElementOptions("cardCvc"));
|
||||
if (autoMount) {
|
||||
this.mountElements();
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
// Check if script is already loaded
|
||||
if (this.stripeScriptLoaded) {
|
||||
// Script already loaded, initialize this instance immediately
|
||||
this.initializeInstance(instanceId, elementIds, autoMount);
|
||||
} else if (!window.document.getElementById("stripe-script")) {
|
||||
// Script not loaded and not loading, start loading it
|
||||
const script = window.document.createElement("script");
|
||||
script.id = "stripe-script";
|
||||
script.src = "https://js.stripe.com/v3?advancedFraudSignals=false";
|
||||
script.onload = async () => {
|
||||
const window$ = window as any;
|
||||
this.stripe = window$.Stripe(process.env.STRIPE_KEY);
|
||||
this.stripeScriptLoaded = true; // Mark as loaded after script loads
|
||||
|
||||
window.document.head.appendChild(script);
|
||||
// Initialize this instance after script loads
|
||||
this.initializeInstance(instanceId, elementIds, autoMount);
|
||||
};
|
||||
window.document.head.appendChild(script);
|
||||
} else {
|
||||
// Script is currently loading, wait for it
|
||||
this.initializeInstance(instanceId, elementIds, autoMount);
|
||||
}
|
||||
}
|
||||
|
||||
mountElements(attempt: number = 1) {
|
||||
setTimeout(() => {
|
||||
if (!this.elements) {
|
||||
this.logService.warning(`Stripe elements are missing, retrying for attempt ${attempt}...`);
|
||||
this.mountElements(attempt + 1);
|
||||
private initializeInstance(
|
||||
instanceId: string,
|
||||
elementIds: { cardNumber: string; cardExpiry: string; cardCvc: string },
|
||||
autoMount: boolean,
|
||||
attempt: number = 1,
|
||||
) {
|
||||
// Wait for stripe to be available if script just loaded
|
||||
if (!this.stripe) {
|
||||
if (attempt < 10) {
|
||||
this.logService.warning(
|
||||
`Stripe not yet loaded for instance ${instanceId}, retrying attempt ${attempt}...`,
|
||||
);
|
||||
setTimeout(
|
||||
() => this.initializeInstance(instanceId, elementIds, autoMount, attempt + 1),
|
||||
50,
|
||||
);
|
||||
} else {
|
||||
const cardNumber = this.elements.getElement("cardNumber");
|
||||
const cardExpiry = this.elements.getElement("cardExpiry");
|
||||
const cardCVC = this.elements.getElement("cardCvc");
|
||||
this.logService.error(
|
||||
`Stripe failed to load for instance ${instanceId} after ${attempt} attempts`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new Elements instance for this component
|
||||
const elements = this.stripe.elements();
|
||||
|
||||
// Store instance data
|
||||
this.instances.set(instanceId, { elements, elementIds });
|
||||
|
||||
// Increment instance count now that instance is successfully initialized
|
||||
this.instanceCount++;
|
||||
|
||||
// Create the card elements
|
||||
setTimeout(() => {
|
||||
elements.create("cardNumber", this.getElementOptions("cardNumber"));
|
||||
elements.create("cardExpiry", this.getElementOptions("cardExpiry"));
|
||||
elements.create("cardCvc", this.getElementOptions("cardCvc"));
|
||||
|
||||
if (autoMount) {
|
||||
this.mountElements(instanceId);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
mountElements(instanceId: string, attempt: number = 1) {
|
||||
setTimeout(() => {
|
||||
const instance = this.instances.get(instanceId);
|
||||
|
||||
if (!instance) {
|
||||
if (attempt < 10) {
|
||||
this.logService.warning(
|
||||
`Stripe instance ${instanceId} not found, retrying for attempt ${attempt}...`,
|
||||
);
|
||||
this.mountElements(instanceId, attempt + 1);
|
||||
} else {
|
||||
this.logService.error(
|
||||
`Stripe instance ${instanceId} not found after ${attempt} attempts`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!instance.elements) {
|
||||
this.logService.warning(
|
||||
`Stripe elements for instance ${instanceId} are missing, retrying for attempt ${attempt}...`,
|
||||
);
|
||||
this.mountElements(instanceId, attempt + 1);
|
||||
} else {
|
||||
const cardNumber = instance.elements.getElement("cardNumber");
|
||||
const cardExpiry = instance.elements.getElement("cardExpiry");
|
||||
const cardCVC = instance.elements.getElement("cardCvc");
|
||||
|
||||
if ([cardNumber, cardExpiry, cardCVC].some((element) => !element)) {
|
||||
this.logService.warning(
|
||||
`Some Stripe card elements are missing, retrying for attempt ${attempt}...`,
|
||||
`Some Stripe card elements for instance ${instanceId} are missing, retrying for attempt ${attempt}...`,
|
||||
);
|
||||
this.mountElements(attempt + 1);
|
||||
this.mountElements(instanceId, attempt + 1);
|
||||
} else {
|
||||
cardNumber.mount(this.elementIds.cardNumber);
|
||||
cardExpiry.mount(this.elementIds.cardExpiry);
|
||||
cardCVC.mount(this.elementIds.cardCvc);
|
||||
cardNumber.mount(instance.elementIds.cardNumber);
|
||||
cardExpiry.mount(instance.elementIds.cardExpiry);
|
||||
cardCVC.mount(instance.elementIds.cardCvc);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
@@ -132,6 +204,9 @@ export class StripeService {
|
||||
* Creates a Stripe [SetupIntent]{@link https://docs.stripe.com/api/setup_intents} and uses the resulting client secret
|
||||
* to invoke the Stripe JS [confirmUsBankAccountSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_us_bank_account_setup} method,
|
||||
* thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}.
|
||||
* @param clientSecret - The client secret from the SetupIntent.
|
||||
* @param bankAccount - The bank account details.
|
||||
* @param billingDetails - Optional billing details.
|
||||
* @returns The ID of the newly created PaymentMethod.
|
||||
*/
|
||||
async setupBankAccountPaymentMethod(
|
||||
@@ -171,13 +246,28 @@ export class StripeService {
|
||||
* Creates a Stripe [SetupIntent]{@link https://docs.stripe.com/api/setup_intents} and uses the resulting client secret
|
||||
* to invoke the Stripe JS [confirmCardSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_card_setup} method,
|
||||
* thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}.
|
||||
* @param instanceId - Unique identifier for the component instance.
|
||||
* @param clientSecret - The client secret from the SetupIntent.
|
||||
* @param billingDetails - Optional billing details.
|
||||
* @returns The ID of the newly created PaymentMethod.
|
||||
*/
|
||||
async setupCardPaymentMethod(
|
||||
instanceId: string,
|
||||
clientSecret: string,
|
||||
billingDetails?: { country: string; postalCode: string },
|
||||
): Promise<string> {
|
||||
const cardNumber = this.elements.getElement("cardNumber");
|
||||
const instance = this.instances.get(instanceId);
|
||||
if (!instance) {
|
||||
const availableInstances = Array.from(this.instances.keys());
|
||||
this.logService.error(
|
||||
`Stripe instance ${instanceId} not found. ` +
|
||||
`Available instances: [${availableInstances.join(", ")}]. ` +
|
||||
`This may occur if the component was destroyed during the payment flow.`,
|
||||
);
|
||||
throw new Error("Payment method initialization failed. Please try again.");
|
||||
}
|
||||
|
||||
const cardNumber = instance.elements.getElement("cardNumber");
|
||||
const request: SetupCardRequest = {
|
||||
payment_method: {
|
||||
card: cardNumber,
|
||||
@@ -200,24 +290,77 @@ export class StripeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes {@link https://docs.stripe.com/js} from the <head> element of the current page as well as all
|
||||
* Stripe-managed <iframe> elements.
|
||||
* Removes the Stripe Elements instance for the specified component.
|
||||
* Only removes the Stripe script and iframes when the last instance is unloaded.
|
||||
* @param instanceId - Unique identifier for the component instance to unload.
|
||||
*/
|
||||
unloadStripe() {
|
||||
const script = window.document.getElementById("stripe-script");
|
||||
window.document.head.removeChild(script);
|
||||
window.setTimeout(() => {
|
||||
const iFrames = Array.from(window.document.querySelectorAll("iframe")).filter(
|
||||
(element) => element.src != null && element.src.indexOf("stripe") > -1,
|
||||
);
|
||||
iFrames.forEach((iFrame) => {
|
||||
try {
|
||||
window.document.body.removeChild(iFrame);
|
||||
} catch (error) {
|
||||
this.logService.error(error);
|
||||
unloadStripe(instanceId: string) {
|
||||
const instance = this.instances.get(instanceId);
|
||||
|
||||
// Only proceed if instance was actually initialized
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Unmount all elements for this instance
|
||||
if (instance.elements) {
|
||||
try {
|
||||
const cardNumber = instance.elements.getElement("cardNumber");
|
||||
const cardExpiry = instance.elements.getElement("cardExpiry");
|
||||
const cardCvc = instance.elements.getElement("cardCvc");
|
||||
|
||||
if (cardNumber) {
|
||||
cardNumber.unmount();
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
if (cardExpiry) {
|
||||
cardExpiry.unmount();
|
||||
}
|
||||
if (cardCvc) {
|
||||
cardCvc.unmount();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error(
|
||||
`Error unmounting Stripe elements for instance ${instanceId}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove instance from map
|
||||
this.instances.delete(instanceId);
|
||||
|
||||
// Decrement instance count (only if instance was initialized)
|
||||
this.instanceCount--;
|
||||
|
||||
// Only remove script and iframes when no instances remain
|
||||
if (this.instanceCount <= 0) {
|
||||
if (this.instanceCount < 0) {
|
||||
this.logService.error(
|
||||
`Stripe instance count became negative (${this.instanceCount}). This indicates a reference counting bug.`,
|
||||
);
|
||||
}
|
||||
this.instanceCount = 0;
|
||||
this.stripeScriptLoaded = false;
|
||||
this.stripe = null;
|
||||
|
||||
const script = window.document.getElementById("stripe-script");
|
||||
if (script) {
|
||||
window.document.head.removeChild(script);
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
const iFrames = Array.from(window.document.querySelectorAll("iframe")).filter(
|
||||
(element) => element.src != null && element.src.indexOf("stripe") > -1,
|
||||
);
|
||||
iFrames.forEach((iFrame) => {
|
||||
try {
|
||||
iFrame.remove();
|
||||
} catch (error) {
|
||||
this.logService.error(error);
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private getElementOptions(element: "cardNumber" | "cardExpiry" | "cardCvc"): any {
|
||||
|
||||
Reference in New Issue
Block a user