Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
PaymentInvalidFormError,
PaymentMethodCancelledError,
RequestError,
SurchargeActionHandler,
SurchargeRequestSender,
} from '@bigcommerce/checkout-sdk/payment-integration-api';
import {
getCreditCardInstrument,
Expand Down Expand Up @@ -60,7 +62,13 @@ describe('AdyenV3PaymentStrategy', () => {
adyenV3ScriptLoader = new AdyenV3ScriptLoader(scriptLoader, stylesheetLoader);
paymentIntegrationService = new PaymentIntegrationServiceMock();

strategy = new AdyenV3PaymentStrategy(paymentIntegrationService, adyenV3ScriptLoader);
strategy = new AdyenV3PaymentStrategy(
paymentIntegrationService,
adyenV3ScriptLoader,
new SurchargeActionHandler(paymentIntegrationService, {
checkSurcharge: jest.fn(),
} as unknown as SurchargeRequestSender),
);

const mockElement = document.createElement('div');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
PaymentMethodCancelledError,
PaymentRequestOptions,
PaymentStrategy,
SurchargeActionHandler,
} from '@bigcommerce/checkout-sdk/payment-integration-api';

export default class Adyenv3PaymentStrategy implements PaymentStrategy {
Expand All @@ -53,10 +54,13 @@ export default class Adyenv3PaymentStrategy implements PaymentStrategy {
private componentState?: AdyenComponentEventState;
private paymentComponent?: AdyenComponent;
private paymentInitializeOptions?: AdyenV3PaymentInitializeOptions;
private _methodId?: string;
private _bin?: string;

constructor(
private paymentIntegrationService: PaymentIntegrationService,
private scriptLoader: AdyenV3ScriptLoader,
private surchargeActionHandler: SurchargeActionHandler,
) {}

async initialize(
Expand All @@ -71,6 +75,7 @@ export default class Adyenv3PaymentStrategy implements PaymentStrategy {
}

this.paymentInitializeOptions = adyenv3;
this._methodId = options.methodId;

const paymentMethod = this.paymentIntegrationService
.getState()
Expand Down Expand Up @@ -276,6 +281,28 @@ export default class Adyenv3PaymentStrategy implements PaymentStrategy {

private _updateComponentState(componentState: AdyenComponentEventState) {
this.componentState = componentState;

// Surcharging: while the card is valid, run the surcharge check (BE) and, if a
// surcharge applies, add it as a checkout fee — so the shopper sees it in the order
// summary before Place Order. The handler dedupes by BIN, so this re-runs only when
// the card changes (not once per keystroke).
if (isCardState(componentState) && componentState.isValid) {
const pm = componentState.data.paymentMethod;

void this.surchargeActionHandler
.applyInFlight({
methodId: this._methodId ?? 'adyenv3',
cardData: {
encryptedCardNumber: pm.encryptedCardNumber,
brand: (pm as unknown as { brand?: string }).brand,
bin: this._bin,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Adyen BIN on re-entry

Medium Severity

this._bin is only updated in onBinValue and is not cleared when the card becomes invalid or the PAN changes. A later valid onChange can reuse the previous BIN for deduplication and surcharge payloads before Adyen emits a new BIN.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c2285b3. Configure here.

},
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error('[surcharge][adyen] in-flight surcharge failed', error);
});
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

private _getLocale(): string | undefined {
Expand Down Expand Up @@ -453,6 +480,9 @@ export default class Adyenv3PaymentStrategy implements PaymentStrategy {
showEmailAddress: false,
onChange: (componentState) => this._updateComponentState(componentState),
onSubmit: (componentState) => this._updateComponentState(componentState),
onBinValue: (binData) => {
this._bin = binData.binValue;
},
...(billingAddress
? { data: this._mapAdyenPlaceholderData(billingAddress, prefillCardHolderName) }
: {}),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { createRequestSender } from '@bigcommerce/request-sender';
import { getScriptLoader, getStylesheetLoader } from '@bigcommerce/script-loader';

import { AdyenV3ScriptLoader } from '@bigcommerce/checkout-sdk/adyen-utils';
import {
PaymentStrategyFactory,
SurchargeActionHandler,
SurchargeRequestSender,
toResolvableModule,
} from '@bigcommerce/checkout-sdk/payment-integration-api';

Expand All @@ -14,6 +17,10 @@ const createAdyenV3PaymentStrategy: PaymentStrategyFactory<AdyenV3PaymentStrateg
return new AdyenV3PaymentStrategy(
paymentIntegrationService,
new AdyenV3ScriptLoader(getScriptLoader(), getStylesheetLoader()),
new SurchargeActionHandler(
paymentIntegrationService,
new SurchargeRequestSender(createRequestSender()),
),
);
};

Expand Down
13 changes: 13 additions & 0 deletions packages/adyen-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,19 @@ export interface AdyenComponentEvents {
onError?(state: AdyenValidationState, component: AdyenComponent): void;

onFieldValid?(state: AdyenValidationState, component: AdyenComponent): void;

/**
* Called as the shopper types the card number, once enough
* digits are entered. `binValue` holds the leading digits (6 by default; up to 8/11
* when the merchant is enrolled in Adyen's extended BIN). Never the full raw PAN.
*/
onBinValue?(data: AdyenBinValueData): void;
}

export interface AdyenBinValueData {
binValue: string;
encryptedBin?: string;
uuid?: string;
}

export interface AdyenClient {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
PaymentArgumentInvalidError,
PaymentInitializeOptions,
PaymentIntegrationService,
SurchargeActionHandler,
SurchargeRequestSender,
} from '@bigcommerce/checkout-sdk/payment-integration-api';
import { PaymentIntegrationServiceMock } from '@bigcommerce/checkout-sdk/payment-integrations-test-utils';

Expand Down Expand Up @@ -85,6 +87,9 @@ describe('BlueSnapDirectCreditCardPaymentStrategy', () => {
paymentIntegrationService,
hostedForm,
bluesnapdirect3ds,
new SurchargeActionHandler(paymentIntegrationService, {
checkSurcharge: jest.fn(),
} as unknown as SurchargeRequestSender),
);

optionsCardValidationWithoutFields = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
PaymentInitializeOptions,
PaymentIntegrationService,
PaymentStrategy,
SurchargeActionHandler,
} from '@bigcommerce/checkout-sdk/payment-integration-api';

import { BlueSnapDirectSdk, BlueSnapDirectThreeDSecureData } from '../types';
Expand All @@ -32,6 +33,7 @@ export default class BlueSnapDirectCreditCardPaymentStrategy implements PaymentS
private _paymentIntegrationService: PaymentIntegrationService,
private _blueSnapDirectHostedForm: BlueSnapDirectHostedForm,
private _blueSnapDirect3ds: BlueSnapDirect3ds,
private _surchargeActionHandler: SurchargeActionHandler,
) {}

async initialize(
Expand Down Expand Up @@ -63,6 +65,37 @@ export default class BlueSnapDirectCreditCardPaymentStrategy implements PaymentS
if (this._shouldUseHostedFields) {
this._blueSnapDirectHostedForm.initialize(this._blueSnapSdk, creditCard.form.fields);

// When the card number is valid, run the surcharge check (BE) and apply the fee so
// the summary shows it. The handler dedupes by BIN, so it re-runs only when the
// card changes. NOTE: `cardData` comes from the latest onType; on paste/autofill
// onValid can fire before onType, so ccBin may be missing — the BE check still
// works off the pfToken, and it re-runs once onType supplies a new BIN.
this._blueSnapDirectHostedForm.setOnCardValidated((cardData) => {
const token = this._getPaymentFieldsToken();

const bin =
typeof cardData === 'object' &&
cardData !== null &&
'ccBin' in cardData &&
typeof cardData.ccBin === 'string'
? cardData.ccBin
: undefined;

void this._surchargeActionHandler
.applyInFlight({
methodId,
cardData: {
pfToken: token,
bin,
providerCardData: cardData,
},
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error('[surcharge][bluesnap] in-flight surcharge failed', error);
});
});

try {
await this._blueSnapDirectHostedForm.attach(
this._getPaymentFieldsToken(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,20 @@ import BluesnapDirectNameOnCardInput from './bluesnap-direct-name-on-card-input'
export default class BlueSnapDirectHostedForm {
private _blueSnapSdk?: BlueSnapDirectSdk;
private _onValidate: HostedFormOptions['onValidate'];
// latest card metadata from onType + a hook fired when
// the card number becomes valid, so the strategy can run the in-flight surcharge.
private _lastCardData?: unknown;
private _onCardValidated?: (cardData: unknown) => void;

constructor(
private _nameOnCardInput: BluesnapDirectNameOnCardInput,
private _hostedInputValidator: BlueSnapHostedInputValidator,
) {}

setOnCardValidated(callback: (cardData: unknown) => void): void {
this._onCardValidated = callback;
}

initialize(blueSnapSdk: BlueSnapDirectSdk, fields?: HostedFieldOptionsMap) {
this._blueSnapSdk = blueSnapSdk;

Expand Down Expand Up @@ -192,11 +200,26 @@ export default class BlueSnapDirectHostedForm {
onFocus: this._usetUiEventCallback(onFocus),
onBlur: this._usetUiEventCallback(onBlur),
onError: this._handleError(onValidate),
onType: (_tagId: HostedFieldTagId, cardType: CardTypeValues) =>
onCardTypeChange?.({ cardType: CardType[cardType] }),
onType: (
_tagId: HostedFieldTagId,
cardType: CardTypeValues,
cardData?: unknown,
) => {
// Surcharging: capture the card metadata Bluesnap exposes in-flight
// so the strategy can forward it to the surcharge check.
this._lastCardData = cardData;
onCardTypeChange?.({ cardType: CardType[cardType] });
},
onEnter: this._usetUiEventCallback(onEnter),
onValid: (tagId: HostedFieldTagId) =>
onValidate?.(this._hostedInputValidator.validate({ tagId })),
onValid: (tagId: HostedFieldTagId) => {
onValidate?.(this._hostedInputValidator.validate({ tagId }));

// Surcharging: once the card number is valid, let the strategy run the
// in-flight surcharge check (before Place Order).
if (tagId === HostedFieldTagId.CardNumber) {
this._onCardValidated?.(this._lastCardData);
}
Comment thread
cursor[bot] marked this conversation as resolved.
},
},
...(isHostedCardFieldOptionsMap(fields) && {
ccnPlaceHolder: fields.cardNumber.placeholder || '',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { createRequestSender } from '@bigcommerce/request-sender';
import { getScriptLoader } from '@bigcommerce/script-loader';

import {
PaymentStrategyFactory,
SurchargeActionHandler,
SurchargeRequestSender,
toResolvableModule,
} from '@bigcommerce/checkout-sdk/payment-integration-api';

Expand All @@ -23,6 +26,10 @@ const createBlueSnapDirectCreditCardPaymentStrategy: PaymentStrategyFactory<
new BlueSnapHostedInputValidator(),
),
new BlueSnapDirect3ds(),
new SurchargeActionHandler(
paymentIntegrationService,
new SurchargeRequestSender(createRequestSender()),
),
);

export default toResolvableModule(createBlueSnapDirectCreditCardPaymentStrategy, [
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/checkout/checkout-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
GiftCertificateAction,
GiftCertificateActionType,
} from '../coupon';
import { FeeAction, FeeActionType } from '../fee';
import { OrderAction, OrderActionType } from '../order';
import { ConsignmentAction, ConsignmentActionType } from '../shipping';
import { SpamProtectionAction, SpamProtectionActionType } from '../spam-protection';
Expand Down Expand Up @@ -44,6 +45,7 @@ function dataReducer(
| BillingAddressAction
| ConsignmentAction
| CouponAction
| FeeAction
| GiftCertificateAction
| OrderAction
| SpamProtectionAction
Expand All @@ -54,6 +56,7 @@ function dataReducer(
case CheckoutActionType.LoadCheckoutSucceeded:
case CheckoutActionType.UpdateCheckoutSucceeded:
case StoreCreditActionType.ApplyStoreCreditSucceeded:
case FeeActionType.ApplyFeesSucceeded:
case BillingAddressActionType.UpdateBillingAddressSucceeded:
case CouponActionType.ApplyCouponSucceeded:
case CouponActionType.RemoveCouponSucceeded:
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/fee/fee-action-creator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createAction, ThunkAction } from '@bigcommerce/data-store';
import { concat, defer, of } from 'rxjs';
import { catchError } from 'rxjs/operators';

import { InternalCheckoutSelectors } from '../checkout';
import { throwErrorAction } from '../common/error';
import { MissingDataError, MissingDataErrorType } from '../common/error/errors';
import { RequestOptions } from '../common/http-request';

import { FeeRequestBody } from './fee';
import { FeeAction, FeeActionType } from './fee-actions';
import FeeRequestSender from './fee-request-sender';

// Mirrors StoreCreditActionCreator. The ApplyFeesSucceeded action carries the updated
// Checkout returned by the Fees API, which the checkout reducer merges into state
// (Checkout.fees + grandTotal + outstandingBalance) — same pattern as store credit /
// coupons, so the order summary re-renders with the surcharge.
export default class FeeActionCreator {
constructor(private _feeRequestSender: FeeRequestSender) {}

applyFees(
fees: FeeRequestBody[],
options?: RequestOptions,
): ThunkAction<FeeAction, InternalCheckoutSelectors> {
return (store) =>
concat(
of(createAction(FeeActionType.ApplyFeesRequested)),
defer(async () => {
const checkout = store.getState().checkout.getCheckout();

if (!checkout) {
throw new MissingDataError(MissingDataErrorType.MissingCheckout);
}

// Send the checkout version for optimistic concurrency (like store credit),
// so an in-flight surcharge doesn't clash with a concurrent checkout update.
const version = options?.version ?? checkout.version;

const { body } = await this._feeRequestSender.applyFees(checkout.id, fees, {
...options,
version,
});

return createAction(FeeActionType.ApplyFeesSucceeded, body);
}),
).pipe(catchError((error) => throwErrorAction(FeeActionType.ApplyFeesFailed, error)));
}
}
24 changes: 24 additions & 0 deletions packages/core/src/fee/fee-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Action } from '@bigcommerce/data-store';

import { Checkout } from '../checkout';
import { RequestError } from '../common/error/errors';

export enum FeeActionType {
ApplyFeesRequested = 'APPLY_FEES_REQUESTED',
ApplyFeesSucceeded = 'APPLY_FEES_SUCCEEDED',
ApplyFeesFailed = 'APPLY_FEES_FAILED',
}

export type FeeAction = ApplyFeesRequestedAction | ApplyFeesSucceededAction | ApplyFeesFailedAction;

export interface ApplyFeesRequestedAction extends Action {
type: FeeActionType.ApplyFeesRequested;
}

export interface ApplyFeesSucceededAction extends Action<Checkout> {
type: FeeActionType.ApplyFeesSucceeded;
}

export interface ApplyFeesFailedAction extends Action<RequestError> {
type: FeeActionType.ApplyFeesFailed;
}
Loading