From c2285b3060f81ee383af29f165846ba759fdc81b Mon Sep 17 00:00:00 2001 From: bc-ania Date: Tue, 14 Jul 2026 13:44:13 +0200 Subject: [PATCH] feat(payment): PI-5431 [SPIKE] [FE] Surcharging (Bluesnap, Adyen) --- .../adyenv3/adyenv3-payment-strategy.spec.ts | 10 ++- .../src/adyenv3/adyenv3-payment-strategy.ts | 30 +++++++ .../create-adyenv3-payment-strategy.ts | 7 ++ packages/adyen-utils/src/types.ts | 13 +++ ...irect-credit-card-payment-strategy.spec.ts | 5 ++ ...nap-direct-credit-card-payment-strategy.ts | 33 +++++++ .../bluesnap-direct-hosted-form.ts | 31 ++++++- ...nap-direct-credit-card-payment-strategy.ts | 7 ++ .../core/src/checkout/checkout-reducer.ts | 3 + packages/core/src/fee/fee-action-creator.ts | 48 ++++++++++ packages/core/src/fee/fee-actions.ts | 24 +++++ packages/core/src/fee/fee-request-sender.ts | 41 +++++++++ packages/core/src/fee/fee.ts | 11 +++ packages/core/src/fee/index.ts | 6 +- .../create-payment-integration-service.ts | 4 + ...efault-payment-integration-service.spec.ts | 2 + .../default-payment-integration-service.ts | 12 +++ .../payment-integration-api/src/fee/fee.ts | 9 ++ .../payment-integration-api/src/fee/index.ts | 2 +- packages/payment-integration-api/src/index.ts | 8 ++ .../src/payment-integration-service.ts | 7 ++ .../src/surcharge/index.ts | 6 ++ .../src/surcharge/surcharge-action-handler.ts | 88 +++++++++++++++++++ .../src/surcharge/surcharge-request-sender.ts | 41 +++++++++ .../payment-integration-service.mock.ts | 2 + 25 files changed, 443 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/fee/fee-action-creator.ts create mode 100644 packages/core/src/fee/fee-actions.ts create mode 100644 packages/core/src/fee/fee-request-sender.ts create mode 100644 packages/payment-integration-api/src/surcharge/index.ts create mode 100644 packages/payment-integration-api/src/surcharge/surcharge-action-handler.ts create mode 100644 packages/payment-integration-api/src/surcharge/surcharge-request-sender.ts diff --git a/packages/adyen-integration/src/adyenv3/adyenv3-payment-strategy.spec.ts b/packages/adyen-integration/src/adyenv3/adyenv3-payment-strategy.spec.ts index 0c00000682..11e977cef2 100644 --- a/packages/adyen-integration/src/adyenv3/adyenv3-payment-strategy.spec.ts +++ b/packages/adyen-integration/src/adyenv3/adyenv3-payment-strategy.spec.ts @@ -24,6 +24,8 @@ import { PaymentInvalidFormError, PaymentMethodCancelledError, RequestError, + SurchargeActionHandler, + SurchargeRequestSender, } from '@bigcommerce/checkout-sdk/payment-integration-api'; import { getCreditCardInstrument, @@ -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'); diff --git a/packages/adyen-integration/src/adyenv3/adyenv3-payment-strategy.ts b/packages/adyen-integration/src/adyenv3/adyenv3-payment-strategy.ts index 5498823e08..dfd216b379 100644 --- a/packages/adyen-integration/src/adyenv3/adyenv3-payment-strategy.ts +++ b/packages/adyen-integration/src/adyenv3/adyenv3-payment-strategy.ts @@ -42,6 +42,7 @@ import { PaymentMethodCancelledError, PaymentRequestOptions, PaymentStrategy, + SurchargeActionHandler, } from '@bigcommerce/checkout-sdk/payment-integration-api'; export default class Adyenv3PaymentStrategy implements PaymentStrategy { @@ -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( @@ -71,6 +75,7 @@ export default class Adyenv3PaymentStrategy implements PaymentStrategy { } this.paymentInitializeOptions = adyenv3; + this._methodId = options.methodId; const paymentMethod = this.paymentIntegrationService .getState() @@ -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, + }, + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('[surcharge][adyen] in-flight surcharge failed', error); + }); + } } private _getLocale(): string | undefined { @@ -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) } : {}), diff --git a/packages/adyen-integration/src/adyenv3/create-adyenv3-payment-strategy.ts b/packages/adyen-integration/src/adyenv3/create-adyenv3-payment-strategy.ts index f377d20f87..ffa358ec18 100644 --- a/packages/adyen-integration/src/adyenv3/create-adyenv3-payment-strategy.ts +++ b/packages/adyen-integration/src/adyenv3/create-adyenv3-payment-strategy.ts @@ -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'; @@ -14,6 +17,10 @@ const createAdyenV3PaymentStrategy: PaymentStrategyFactory { paymentIntegrationService, hostedForm, bluesnapdirect3ds, + new SurchargeActionHandler(paymentIntegrationService, { + checkSurcharge: jest.fn(), + } as unknown as SurchargeRequestSender), ); optionsCardValidationWithoutFields = { diff --git a/packages/bluesnap-direct-integration/src/bluesnap-direct-credit-card/bluesnap-direct-credit-card-payment-strategy.ts b/packages/bluesnap-direct-integration/src/bluesnap-direct-credit-card/bluesnap-direct-credit-card-payment-strategy.ts index a032cdd526..3a00936761 100644 --- a/packages/bluesnap-direct-integration/src/bluesnap-direct-credit-card/bluesnap-direct-credit-card-payment-strategy.ts +++ b/packages/bluesnap-direct-integration/src/bluesnap-direct-credit-card/bluesnap-direct-credit-card-payment-strategy.ts @@ -12,6 +12,7 @@ import { PaymentInitializeOptions, PaymentIntegrationService, PaymentStrategy, + SurchargeActionHandler, } from '@bigcommerce/checkout-sdk/payment-integration-api'; import { BlueSnapDirectSdk, BlueSnapDirectThreeDSecureData } from '../types'; @@ -32,6 +33,7 @@ export default class BlueSnapDirectCreditCardPaymentStrategy implements PaymentS private _paymentIntegrationService: PaymentIntegrationService, private _blueSnapDirectHostedForm: BlueSnapDirectHostedForm, private _blueSnapDirect3ds: BlueSnapDirect3ds, + private _surchargeActionHandler: SurchargeActionHandler, ) {} async initialize( @@ -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(), diff --git a/packages/bluesnap-direct-integration/src/bluesnap-direct-credit-card/bluesnap-direct-hosted-form.ts b/packages/bluesnap-direct-integration/src/bluesnap-direct-credit-card/bluesnap-direct-hosted-form.ts index 9ce78e1066..66a1a004e4 100644 --- a/packages/bluesnap-direct-integration/src/bluesnap-direct-credit-card/bluesnap-direct-hosted-form.ts +++ b/packages/bluesnap-direct-integration/src/bluesnap-direct-credit-card/bluesnap-direct-hosted-form.ts @@ -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; @@ -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); + } + }, }, ...(isHostedCardFieldOptionsMap(fields) && { ccnPlaceHolder: fields.cardNumber.placeholder || '', diff --git a/packages/bluesnap-direct-integration/src/bluesnap-direct-credit-card/create-bluesnap-direct-credit-card-payment-strategy.ts b/packages/bluesnap-direct-integration/src/bluesnap-direct-credit-card/create-bluesnap-direct-credit-card-payment-strategy.ts index 4f0764f775..4727b6778e 100644 --- a/packages/bluesnap-direct-integration/src/bluesnap-direct-credit-card/create-bluesnap-direct-credit-card-payment-strategy.ts +++ b/packages/bluesnap-direct-integration/src/bluesnap-direct-credit-card/create-bluesnap-direct-credit-card-payment-strategy.ts @@ -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'; @@ -23,6 +26,10 @@ const createBlueSnapDirectCreditCardPaymentStrategy: PaymentStrategyFactory< new BlueSnapHostedInputValidator(), ), new BlueSnapDirect3ds(), + new SurchargeActionHandler( + paymentIntegrationService, + new SurchargeRequestSender(createRequestSender()), + ), ); export default toResolvableModule(createBlueSnapDirectCreditCardPaymentStrategy, [ diff --git a/packages/core/src/checkout/checkout-reducer.ts b/packages/core/src/checkout/checkout-reducer.ts index 6ae93c94f2..14738bd2ac 100644 --- a/packages/core/src/checkout/checkout-reducer.ts +++ b/packages/core/src/checkout/checkout-reducer.ts @@ -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'; @@ -44,6 +45,7 @@ function dataReducer( | BillingAddressAction | ConsignmentAction | CouponAction + | FeeAction | GiftCertificateAction | OrderAction | SpamProtectionAction @@ -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: diff --git a/packages/core/src/fee/fee-action-creator.ts b/packages/core/src/fee/fee-action-creator.ts new file mode 100644 index 0000000000..bbb009a3e2 --- /dev/null +++ b/packages/core/src/fee/fee-action-creator.ts @@ -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 { + 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))); + } +} diff --git a/packages/core/src/fee/fee-actions.ts b/packages/core/src/fee/fee-actions.ts new file mode 100644 index 0000000000..bb90e6b148 --- /dev/null +++ b/packages/core/src/fee/fee-actions.ts @@ -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 { + type: FeeActionType.ApplyFeesSucceeded; +} + +export interface ApplyFeesFailedAction extends Action { + type: FeeActionType.ApplyFeesFailed; +} diff --git a/packages/core/src/fee/fee-request-sender.ts b/packages/core/src/fee/fee-request-sender.ts new file mode 100644 index 0000000000..8f85a40410 --- /dev/null +++ b/packages/core/src/fee/fee-request-sender.ts @@ -0,0 +1,41 @@ +import { RequestSender, Response } from '@bigcommerce/request-sender'; + +import { Checkout, CHECKOUT_DEFAULT_INCLUDES } from '../checkout'; +import { + ContentType, + joinIncludes, + RequestOptions, + SDK_VERSION_HEADERS, +} from '../common/http-request'; + +import { FeeRequestBody } from './fee'; + +// Storefront proxy over the v3 Checkout Fees API. +// Expects BE to expose POST /api/storefront/checkouts/{id}/fees -> /v3/checkouts/{id}/fees. +export default class FeeRequestSender { + constructor(private _requestSender: RequestSender) {} + + applyFees( + checkoutId: string, + fees: FeeRequestBody[], + { timeout, version }: RequestOptions = {}, + ): Promise> { + // NOTE: this storefront proxy endpoint is not implemented on BE yet. + const url = `/api/storefront/checkouts/${checkoutId}/fees`; + const headers = { + Accept: ContentType.JsonV1, + ...SDK_VERSION_HEADERS, + }; + + return this._requestSender.post(url, { + headers, + timeout, + params: { + // Make sure the returned checkout carries the newly applied fees. + include: joinIncludes([...CHECKOUT_DEFAULT_INCLUDES, 'fees']), + }, + // Pass the checkout version for optimistic concurrency, like store credit. + body: { fees, version }, + }); + } +} diff --git a/packages/core/src/fee/fee.ts b/packages/core/src/fee/fee.ts index 1a1b5da5ee..d1485be638 100644 --- a/packages/core/src/fee/fee.ts +++ b/packages/core/src/fee/fee.ts @@ -6,3 +6,14 @@ export default interface Fee { cost: number; source: string; } + +// request shape for the Checkout Fees API. +// Maps 1:1 to POST /v3/checkouts/{checkoutId}/fees. +export interface FeeRequestBody { + type: 'custom_fee'; + name: string; + display_name: string; + cost: number; + source: string; + tax_class_id?: number; +} diff --git a/packages/core/src/fee/index.ts b/packages/core/src/fee/index.ts index dccb7995ac..2f4ea11256 100644 --- a/packages/core/src/fee/index.ts +++ b/packages/core/src/fee/index.ts @@ -1 +1,5 @@ -export { default as Fee } from './fee'; +export { default as Fee, FeeRequestBody } from './fee'; + +export { default as FeeRequestSender } from './fee-request-sender'; +export { default as FeeActionCreator } from './fee-action-creator'; +export * from './fee-actions'; diff --git a/packages/core/src/payment-integration/create-payment-integration-service.ts b/packages/core/src/payment-integration/create-payment-integration-service.ts index 961da2f399..cf429cd03b 100644 --- a/packages/core/src/payment-integration/create-payment-integration-service.ts +++ b/packages/core/src/payment-integration/create-payment-integration-service.ts @@ -15,6 +15,7 @@ import { ConfigActionCreator, ConfigRequestSender } from '../config'; import CouponActionCreator from '../coupon/coupon-action-creator'; import CouponRequestSender from '../coupon/coupon-request-sender'; import { CustomerActionCreator, CustomerRequestSender } from '../customer'; +import { FeeActionCreator, FeeRequestSender } from '../fee'; import { FormFieldsActionCreator, FormFieldsRequestSender } from '../form'; import { HostedFormFactory } from '../hosted-form'; import { OrderActionCreator, OrderRequestSender } from '../order'; @@ -119,6 +120,8 @@ export default function createPaymentIntegrationService( new StoreCreditRequestSender(requestSender), ); + const feeActionCreator = new FeeActionCreator(new FeeRequestSender(requestSender)); + const applyCouponActionCreator = new CouponActionCreator( new CouponRequestSender(requestSender), ); @@ -161,6 +164,7 @@ export default function createPaymentIntegrationService( customerActionCreator, cartRequestSender, storeCreditActionCreator, + feeActionCreator, applyCouponActionCreator, spamProtectionActionCreator, paymentProviderCustomerActionCreator, diff --git a/packages/core/src/payment-integration/default-payment-integration-service.spec.ts b/packages/core/src/payment-integration/default-payment-integration-service.spec.ts index 8f869f038c..26c3a57ed8 100644 --- a/packages/core/src/payment-integration/default-payment-integration-service.spec.ts +++ b/packages/core/src/payment-integration/default-payment-integration-service.spec.ts @@ -26,6 +26,7 @@ import { DataStoreProjection } from '../common/data-store'; import { getResponse } from '../common/http-request/responses.mock'; import { CouponActionCreator } from '../coupon'; import { CustomerActionCreator } from '../customer'; +import { FeeActionCreator } from '../fee'; import { HostedForm, HostedFormFactory } from '../hosted-form'; import { OrderActionCreator } from '../order'; import { getOrder } from '../order/orders.mock'; @@ -310,6 +311,7 @@ describe('DefaultPaymentIntegrationService', () => { customerActionCreator as CustomerActionCreator, cartRequestSender, storeCreditActionCreator as StoreCreditActionCreator, + { applyFees: jest.fn() } as unknown as FeeActionCreator, couponActionCreator as CouponActionCreator, spamProtectionActionCreator as SpamProtectionActionCreator, paymentProviderCustomerActionCreator, diff --git a/packages/core/src/payment-integration/default-payment-integration-service.ts b/packages/core/src/payment-integration/default-payment-integration-service.ts index 88b647a5de..8f14335417 100644 --- a/packages/core/src/payment-integration/default-payment-integration-service.ts +++ b/packages/core/src/payment-integration/default-payment-integration-service.ts @@ -2,6 +2,7 @@ import { BillingAddressRequestBody, BuyNowCartRequestBody, Cart, + FeeRequestBody, HostedForm, HostedFormOptions, InitializeOffsitePaymentConfig, @@ -19,6 +20,7 @@ import { Checkout, CheckoutActionCreator, CheckoutStore, CheckoutValidator } fro import { DataStoreProjection } from '../common/data-store'; import CouponActionCreator from '../coupon/coupon-action-creator'; import { CustomerActionCreator, CustomerCredentials } from '../customer'; +import { FeeActionCreator } from '../fee'; import { HostedFormFactory } from '../hosted-form'; import { OrderActionCreator } from '../order'; import { PaymentAdditionalAction } from '../payment'; @@ -55,6 +57,7 @@ export default class DefaultPaymentIntegrationService implements PaymentIntegrat private _customerActionCreator: CustomerActionCreator, private _cartRequestSender: CartRequestSender, private _storeCreditActionCreator: StoreCreditActionCreator, + private _feeActionCreator: FeeActionCreator, private _couponActionCreator: CouponActionCreator, private _spamProtectionActionCreator: SpamProtectionActionCreator, private _paymentProviderCustomerActionCreator: PaymentProviderCustomerActionCreator, @@ -219,6 +222,15 @@ export default class DefaultPaymentIntegrationService implements PaymentIntegrat return this._storeProjection.getState(); } + async applyFees( + fees: FeeRequestBody[], + options?: RequestOptions, + ): Promise { + await this._store.dispatch(this._feeActionCreator.applyFees(fees, options)); + + return this._storeProjection.getState(); + } + async applyCoupon( coupon: string, options?: RequestOptions, diff --git a/packages/payment-integration-api/src/fee/fee.ts b/packages/payment-integration-api/src/fee/fee.ts index 1a1b5da5ee..8a4ab7b1a2 100644 --- a/packages/payment-integration-api/src/fee/fee.ts +++ b/packages/payment-integration-api/src/fee/fee.ts @@ -6,3 +6,12 @@ export default interface Fee { cost: number; source: string; } + +export interface FeeRequestBody { + type: 'custom_fee'; + name: string; + display_name: string; + cost: number; + source: string; + tax_class_id?: number; +} diff --git a/packages/payment-integration-api/src/fee/index.ts b/packages/payment-integration-api/src/fee/index.ts index dccb7995ac..e2e0e141f4 100644 --- a/packages/payment-integration-api/src/fee/index.ts +++ b/packages/payment-integration-api/src/fee/index.ts @@ -1 +1 @@ -export { default as Fee } from './fee'; +export { default as Fee, FeeRequestBody } from './fee'; diff --git a/packages/payment-integration-api/src/index.ts b/packages/payment-integration-api/src/index.ts index 739a735db3..b3962ea522 100644 --- a/packages/payment-integration-api/src/index.ts +++ b/packages/payment-integration-api/src/index.ts @@ -19,6 +19,7 @@ export { PhysicalItem, } from './cart'; export { Checkout } from './checkout'; +export { Fee, FeeRequestBody } from './fee'; export { BrowserInfo, getBrowserInfo } from './common/browser-info'; export { CancellablePromise } from './common/cancellable-promise'; export { ContentType, INTERNAL_USE_ONLY, SDK_VERSION_HEADERS } from './common/http-request'; @@ -204,3 +205,10 @@ export { default as isResolvableModule } from './is-resolvable-module'; export { default as toResolvableModule } from './to-resolvable-module'; export { RemoteCheckoutActionType } from './remote-checkout'; export { default as UnsupportedBrowserError } from './unsupported-browser-error'; +export { + SurchargeActionHandler, + SurchargeRequestSender, + SurchargeCheckInput, + SurchargeCheckResponse, + SURCHARGE_FEE_NAME, +} from './surcharge'; diff --git a/packages/payment-integration-api/src/payment-integration-service.ts b/packages/payment-integration-api/src/payment-integration-service.ts index 9def46f313..0bbe6536a3 100644 --- a/packages/payment-integration-api/src/payment-integration-service.ts +++ b/packages/payment-integration-api/src/payment-integration-service.ts @@ -2,6 +2,7 @@ import { BillingAddressRequestBody } from './billing'; import { BuyNowCartRequestBody, Cart } from './cart'; import { Checkout } from './checkout'; import { CustomerCredentials } from './customer'; +import { FeeRequestBody } from './fee'; import { HostedForm, HostedFormOptions } from './hosted-form'; import { OrderRequestBody } from './order'; import { InitializeOffsitePaymentConfig, Payment, PaymentAdditionalAction } from './payment'; @@ -77,6 +78,12 @@ export default interface PaymentIntegrationService { options?: RequestOptions, ): Promise; + // apply custom fees (e.g. a surcharge) to the checkout. + applyFees( + fees: FeeRequestBody[], + options?: RequestOptions, + ): Promise; + applyCoupon(coupon: string, options?: RequestOptions): Promise; removeCoupon(couponId: string, options?: RequestOptions): Promise; diff --git a/packages/payment-integration-api/src/surcharge/index.ts b/packages/payment-integration-api/src/surcharge/index.ts new file mode 100644 index 0000000000..ee258e4e50 --- /dev/null +++ b/packages/payment-integration-api/src/surcharge/index.ts @@ -0,0 +1,6 @@ +export { default as SurchargeActionHandler, SURCHARGE_FEE_NAME } from './surcharge-action-handler'; +export { + default as SurchargeRequestSender, + SurchargeCheckInput, + SurchargeCheckResponse, +} from './surcharge-request-sender'; diff --git a/packages/payment-integration-api/src/surcharge/surcharge-action-handler.ts b/packages/payment-integration-api/src/surcharge/surcharge-action-handler.ts new file mode 100644 index 0000000000..96cbe646cb --- /dev/null +++ b/packages/payment-integration-api/src/surcharge/surcharge-action-handler.ts @@ -0,0 +1,88 @@ +import { Fee, FeeRequestBody } from '../fee'; +import PaymentIntegrationService from '../payment-integration-service'; + +import SurchargeRequestSender, { SurchargeCheckInput } from './surcharge-request-sender'; + +// Identifies the surcharge fee so we can detect it (avoid duplicates) and recognise +// surcharge-driven checkout changes on the UI side. +export const SURCHARGE_FEE_NAME = 'corporate_card_surcharge'; + +export default class SurchargeActionHandler { + // BIN the surcharge was last checked for; used to re-run only when the card changes. + private _lastCheckedBin?: string; + // Serializes checks so overlapping card events can't race past _hasSurcharge and + // apply the fee twice. + private _isChecking = false; + + constructor( + private _paymentIntegrationService: PaymentIntegrationService, + private _surchargeRequestSender: SurchargeRequestSender, + ) {} + + /** + * Called in-flight — while the shopper fills / edits the card, BEFORE Place Order. + * Asks BE whether a surcharge applies for the current card and, if so, applies it as a + * checkout fee. Re-runs whenever the card (BIN) changes so the surcharge stays in sync. + */ + async applyInFlight(input: SurchargeCheckInput): Promise { + const bin = typeof input.cardData.bin === 'string' ? input.cardData.bin : undefined; + + // Only re-run when the card (BIN) changes. Set synchronously before the await so + // concurrent onChange/onValid calls for the same card are deduped. + if (bin && bin === this._lastCheckedBin) { + return; + } + + // Concurrency guard: allow only one check at a time + if (this._isChecking) { + return; + } + + this._isChecking = true; + this._lastCheckedBin = bin; + + try { + const checkout = this._paymentIntegrationService.getState().getCheckoutOrThrow(); + + const { body } = await this._surchargeRequestSender.checkSurcharge(checkout.id, input); + + if (!body.eligible || body.amount <= 0) { + // TODO (surcharging): if a surcharge fee is already applied and the new card is + // NOT eligible, the stale fee must be removed server-side (needs a BE remove + // endpoint). The Fees API only adds fees today. + return; + } + + // TODO (surcharging): if a surcharge for a PREVIOUS card is already applied, it must + // be replaced server-side when the card changes (needs a BE remove/prorate endpoint). + // Until then we avoid stacking duplicate fees by only applying when none exists yet. + if (this._hasSurcharge()) { + return; + } + + const fee: FeeRequestBody = { + type: 'custom_fee', + name: body.name, + display_name: body.displayName, + cost: body.amount, + source: body.source, + tax_class_id: body.taxClassId, + }; + + // applyFees() returns the updated Checkout, merged into state by the checkout reducer + // (Checkout.fees + grandTotal + outstandingBalance) so the summary re-renders. + await this._paymentIntegrationService.applyFees([fee]); + } catch (error) { + this._lastCheckedBin = undefined; // allow a retry on the next valid change + throw error; + } finally { + this._isChecking = false; + } + } + + private _hasSurcharge(): boolean { + const checkout = this._paymentIntegrationService.getState().getCheckout(); + + return Boolean(checkout?.fees?.some((fee: Fee) => fee.name === SURCHARGE_FEE_NAME)); + } +} diff --git a/packages/payment-integration-api/src/surcharge/surcharge-request-sender.ts b/packages/payment-integration-api/src/surcharge/surcharge-request-sender.ts new file mode 100644 index 0000000000..8eb22807b4 --- /dev/null +++ b/packages/payment-integration-api/src/surcharge/surcharge-request-sender.ts @@ -0,0 +1,41 @@ +import { RequestSender, Response } from '@bigcommerce/request-sender'; + +import { ContentType, SDK_VERSION_HEADERS } from '../common/http-request'; +import { RequestOptions } from '../util-types'; + +// Provider-agnostic: FE forwards whatever card handle it holds (Adyen +// encryptedCardNumber / BIN, Bluesnap pfToken / ccBin). BE proxies it to the provider +// (Adyen /cardDetails, Bluesnap /surcharge/calculate), applies compliance rules, and +// returns a normalized surcharge value. +export interface SurchargeCheckInput { + methodId: string; + cardData: Record; +} + +export interface SurchargeCheckResponse { + eligible: boolean; + amount: number; + displayName: string; + name: string; + source: string; + taxClassId?: number; +} + +export default class SurchargeRequestSender { + constructor(private _requestSender: RequestSender) {} + + checkSurcharge( + checkoutId: string, + body: SurchargeCheckInput, + { timeout }: RequestOptions = {}, + ): Promise> { + // NOTE: this endpoint is not implemented on the BE yet. + const url = `/api/storefront/checkouts/${checkoutId}/surcharge-check`; + const headers = { + Accept: ContentType.JsonV1, + ...SDK_VERSION_HEADERS, + }; + + return this._requestSender.post(url, { headers, timeout, body }); + } +} diff --git a/packages/payment-integrations-test-utils/src/test-utils/payment-integration-service.mock.ts b/packages/payment-integrations-test-utils/src/test-utils/payment-integration-service.mock.ts index 7c3bc7f15c..7a711d42dc 100644 --- a/packages/payment-integrations-test-utils/src/test-utils/payment-integration-service.mock.ts +++ b/packages/payment-integrations-test-utils/src/test-utils/payment-integration-service.mock.ts @@ -88,6 +88,7 @@ const signInCustomer = jest.fn(); const signOutCustomer = jest.fn(); const selectShippingOption = jest.fn(); const applyStoreCredit = jest.fn(); +const applyFees = jest.fn(); const verifyCheckoutSpamProtection = jest.fn(); const updatePaymentProviderCustomer = jest.fn(); const initializePayment = jest.fn(); @@ -127,6 +128,7 @@ const PaymentIntegrationServiceMock = jest signOutCustomer, selectShippingOption, applyStoreCredit, + applyFees, applyCoupon, removeCoupon, verifyCheckoutSpamProtection,