Skip to content

Commit a93938c

Browse files
committed
fix(dropin): solved issue when checking the onPayButtonClick in a multi gift card scenario
1 parent 35d46dd commit a93938c

4 files changed

Lines changed: 181 additions & 14 deletions

File tree

enabler/src/dropin/dropin-embedded.ts

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export class DropinEmbeddedBuilder implements PaymentDropinBuilder {
4545
private processorUrl: string;
4646
private sessionId: string;
4747
private storedPaymentMethodsConfig: StoredPaymentMethodsConfig;
48+
private getRemainingAmount: (() => number) | undefined;
4849

4950
constructor(baseOptions: BaseOptions) {
5051
this.adyenCheckout = baseOptions.adyenCheckout;
@@ -53,6 +54,7 @@ export class DropinEmbeddedBuilder implements PaymentDropinBuilder {
5354
this.storedPaymentMethodsConfig = baseOptions.storedPaymentMethodsConfig;
5455
this.processorUrl = baseOptions.processorUrl;
5556
this.sessionId = baseOptions.sessionId;
57+
this.getRemainingAmount = baseOptions.getRemainingAmount;
5658
}
5759

5860
build(config: DropinOptions): DropinComponent {
@@ -63,6 +65,7 @@ export class DropinEmbeddedBuilder implements PaymentDropinBuilder {
6365
storedPaymentMethodsConfig: this.storedPaymentMethodsConfig,
6466
processorUrl: this.processorUrl,
6567
sessionId: this.sessionId,
68+
getRemainingAmount: this.getRemainingAmount,
6669
});
6770

6871
dropin.init();
@@ -81,6 +84,8 @@ export class DropinComponents implements DropinComponent {
8184
private dropinConfigOverride: Record<string, any>;
8285
private storedPaymentMethodsConfig: StoredPaymentMethodsConfig;
8386
private apiClient: ProcessorApiClient;
87+
private lastKnownGiftCardBalance: number | null = null;
88+
private getRemainingAmount: (() => number) | undefined;
8489

8590
constructor(opts: {
8691
adyenCheckout: ICore;
@@ -89,13 +94,16 @@ export class DropinComponents implements DropinComponent {
8994
storedPaymentMethodsConfig: StoredPaymentMethodsConfig;
9095
processorUrl: string;
9196
sessionId: string;
97+
getRemainingAmount?: () => number;
9298
}) {
9399
this.dropinOptions = opts.dropinOptions;
94100
this.adyenCheckout = opts.adyenCheckout;
95101
this.dropinConfigOverride = opts.dropinConfigOverride;
96102
this.storedPaymentMethodsConfig = opts.storedPaymentMethodsConfig;
97103
this.apiClient = new ProcessorApiClient({ processorUrl: opts.processorUrl, sessionId: opts.sessionId });
104+
this.getRemainingAmount = opts.getRemainingAmount;
98105

106+
this.overrideBalanceCheckCallback();
99107
this.overrideOnSubmit();
100108
}
101109

@@ -133,7 +141,7 @@ export class DropinComponents implements DropinComponent {
133141
}
134142
},
135143
onReady: () => {
136-
if (this.dropinOptions.onDropinReady) {
144+
if (this.dropinOptions?.onDropinReady) {
137145
this.dropinOptions
138146
.onDropinReady()
139147
.then(() => {})
@@ -288,16 +296,51 @@ export class DropinComponents implements DropinComponent {
288296
this.adyenCheckout.options.onSubmit = async (state: SubmitData, component: Dropin, actions: SubmitActions) => {
289297
const paymentMethod = state.data.paymentMethod.type;
290298
const hasOnClick = component.props.paymentMethodsConfiguration?.[paymentMethod]?.onClick;
291-
const isGiftCardResubmit = paymentMethod === 'giftcard' && !!state.data.order;
292-
if (!hasOnClick && !isGiftCardResubmit && this.dropinOptions.onPayButtonClick) {
293-
try {
294-
await this.dropinOptions.onPayButtonClick();
295-
} catch (e) {
296-
component.setStatus("ready");
297-
return;
298-
}
299+
const isIntermediateSplitPayment = this.isIntermediateSplitPayment(state);
300+
301+
if (!hasOnClick && !isIntermediateSplitPayment && this.dropinOptions?.onPayButtonClick) {
302+
const cancelled = await this.runOnPayButtonClick(component);
303+
if (cancelled) return;
299304
}
305+
300306
return await parentOnSubmit(state, component, actions);
301307
};
302308
}
309+
310+
private overrideBalanceCheckCallback() {
311+
const parentOnBalanceCheck = this.adyenCheckout.options.onBalanceCheck;
312+
if (!parentOnBalanceCheck) return;
313+
314+
this.adyenCheckout.options.onBalanceCheck = (resolve, reject, data) => {
315+
return parentOnBalanceCheck(
316+
(result) => {
317+
this.lastKnownGiftCardBalance = result.balance?.value ?? null;
318+
resolve(result);
319+
},
320+
reject,
321+
data,
322+
);
323+
};
324+
}
325+
326+
private isIntermediateSplitPayment(state: SubmitData): boolean {
327+
const order = state.data.order as { orderData: string; pspReference: string } | undefined;
328+
if (!order) return false;
329+
330+
const paymentType = state.data.paymentMethod?.type ?? "";
331+
if (paymentType !== "giftcard") return false;
332+
333+
const remainingAmount = this.getRemainingAmount?.() ?? 0;
334+
return this.lastKnownGiftCardBalance !== null && this.lastKnownGiftCardBalance < remainingAmount;
335+
}
336+
337+
private async runOnPayButtonClick(component: Dropin): Promise<boolean> {
338+
try {
339+
await this.dropinOptions!.onPayButtonClick!();
340+
return false;
341+
} catch {
342+
component.setStatus("ready");
343+
return true;
344+
}
345+
}
303346
}

enabler/src/payment-enabler/adyen-init-session.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export class AdyenInitWithSessionFlow implements AdyenInit {
5252
]);
5353

5454
let orderAmount: { value: number; currency: string } | undefined;
55+
let currentRemainingAmount = 0;
5556
let storedPaymentMethodsList: CocoStoredPaymentMethod[] = [];
5657
if (configJson.storedPaymentMethodsConfig?.isEnabled === true) {
5758
const storedPaymentMethods = await this.apiClient.getStoredPaymentMethods();
@@ -97,6 +98,7 @@ export class AdyenInitWithSessionFlow implements AdyenInit {
9798

9899
const data = await this.apiClient.createPayment(reqData);
99100
paymentReference = data.paymentReference;
101+
currentRemainingAmount = data.order?.remainingAmount?.value ?? 0;
100102
if (data.action) {
101103
if (["threeDS2", "qrCode"].includes(data.action.type) && this.initOptions.onActionRequired) {
102104
this.initOptions.onActionRequired({ type: "fullscreen" });
@@ -105,8 +107,8 @@ export class AdyenInitWithSessionFlow implements AdyenInit {
105107
} else {
106108
if (data.resultCode === "Authorised" || data.resultCode === "Pending") {
107109
component.setStatus("success");
108-
const isPartialPayment = (data.order?.remainingAmount?.value ?? 0) > 0;
109-
if (!isPartialPayment) {
110+
const remainingAmount = data.order?.remainingAmount?.value ?? 0;
111+
if (remainingAmount === 0) {
110112
this.handleComplete({
111113
isSuccess: true,
112114
component: component,
@@ -176,6 +178,7 @@ export class AdyenInitWithSessionFlow implements AdyenInit {
176178
try {
177179
const order = await this.apiClient.createOrder();
178180
orderAmount = order.amount;
181+
currentRemainingAmount = order.remainingAmount?.value ?? 0;
179182
resolve({
180183
orderData: order.orderData,
181184
pspReference: order.pspReference ?? "",
@@ -225,6 +228,7 @@ export class AdyenInitWithSessionFlow implements AdyenInit {
225228
adyenCheckout,
226229
sessionId: this.initOptions.sessionId,
227230
processorUrl: this.initOptions.processorUrl,
231+
getRemainingAmount: () => currentRemainingAmount,
228232
currencyCode: this.initOptions.currencyCode,
229233
applePayConfig: this.applePayConfig,
230234
paymentComponentsConfigOverride: this.paymentComponentsConfigOverride,

processor/src/services/adyen-payment.service.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,13 @@ export class AdyenPaymentService extends AbstractPaymentService {
369369
)
370370
.reduce((sum, payment) => sum + payment.amountPlanned.centAmount, 0);
371371

372+
const centAmount = basePrice.centAmount - paidCentAmount - giftCardCentAmount;
373+
if (centAmount <= 0) {
374+
throw new ErrorInvalidOperation('Cart has no remaining amount to pay');
375+
}
376+
372377
return {
373-
centAmount: basePrice.centAmount - paidCentAmount - giftCardCentAmount,
378+
centAmount,
374379
currencyCode: basePrice.currencyCode,
375380
fractionDigits: basePrice.fractionDigits,
376381
};
@@ -1702,7 +1707,7 @@ export class AdyenPaymentService extends AbstractPaymentService {
17021707
});
17031708
if (balanceResponse.balance) {
17041709
return {
1705-
centAmount: balanceResponse.balance.value,
1710+
centAmount: Math.min(balanceResponse.balance.value, cartAmount.centAmount),
17061711
currencyCode: balanceResponse.balance.currency,
17071712
fractionDigits: cartAmount.fractionDigits,
17081713
};

processor/test/services/adyen-payment.service.spec.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
TokenizationAlreadyExistingDetailsNotificationRequest,
6262
} from '@adyen/api-library/lib/src/typings/tokenizationWebhooks/models';
6363
import { RecurringApi } from '@adyen/api-library/lib/src/services/checkout/recurringApi';
64+
import { OrdersApi } from '@adyen/api-library/lib/src/services/checkout/ordersApi';
6465

6566
import * as FastifyContext from '../../src/libs/fastify/context/context';
6667
import { StoredPaymentMethod } from '../../src/dtos/stored-payment-methods.dto';
@@ -128,7 +129,7 @@ describe('adyen-payment.service', () => {
128129

129130
test('getSupportedPaymentComponents', async () => {
130131
const result: SupportedPaymentComponentsSchemaDTO = await paymentService.getSupportedPaymentComponents();
131-
expect(result?.components).toHaveLength(24);
132+
expect(result?.components).toHaveLength(27);
132133
expect(result?.components[0]?.type).toStrictEqual('afterpay');
133134
expect(result?.components[1]?.type).toStrictEqual('applepay');
134135
expect(result?.components[2]?.type).toStrictEqual('bancontactcard');
@@ -153,6 +154,9 @@ describe('adyen-payment.service', () => {
153154
expect(result?.components[21]?.type).toStrictEqual('clearpay');
154155
expect(result?.components[22]?.type).toStrictEqual('mbway');
155156
expect(result?.components[23]?.type).toStrictEqual('trustly');
157+
expect(result?.components[24]?.type).toStrictEqual('zip');
158+
expect(result?.components[25]?.type).toStrictEqual('wechatpay');
159+
expect(result?.components[26]?.type).toStrictEqual('alipay');
156160
});
157161

158162
test('getStatus', async () => {
@@ -2215,5 +2219,116 @@ describe('adyen-payment.service', () => {
22152219
const result = service.calculateRemainingAmount(cartWithPayments([p1, p2]));
22162220
expect(result.centAmount).toBe(10000 - 1000 - 3000);
22172221
});
2222+
2223+
test('throws ErrorInvalidOperation when remaining amount is exactly zero', () => {
2224+
const payment = approvedPayment({
2225+
amountPlanned: { type: 'centPrecision', centAmount: 10000, currencyCode: 'USD', fractionDigits: 2 },
2226+
transactions: [
2227+
{
2228+
id: 'tx-1',
2229+
type: 'Authorization',
2230+
state: 'Success',
2231+
amount: { type: 'centPrecision', centAmount: 10000, currencyCode: 'USD', fractionDigits: 2 },
2232+
timestamp: '2024-01-01T00:00:00Z',
2233+
},
2234+
],
2235+
});
2236+
expect(() => service.calculateRemainingAmount(cartWithPayments([payment]))).toThrow(ErrorInvalidOperation);
2237+
});
2238+
2239+
test('throws ErrorInvalidOperation when remaining amount would be negative', () => {
2240+
jest.spyOn(FastifyContext, 'getGiftCardPlannedCentAmountFromContext').mockReturnValue(10001);
2241+
expect(() => service.calculateRemainingAmount(baseCart())).toThrow(ErrorInvalidOperation);
2242+
});
2243+
});
2244+
2245+
describe('createPayment - gift card split payment (getAmountToPay)', () => {
2246+
const giftCardPaymentOpts: { data: CreatePaymentRequestDTO } = {
2247+
data: {
2248+
paymentMethod: { type: 'giftcard', brand: 'givex' } as Record<string, string>,
2249+
order: { orderData: 'some-order-data', pspReference: 'ORDER-PSP-1' },
2250+
},
2251+
};
2252+
2253+
beforeEach(() => {
2254+
jest.spyOn(DefaultCartService.prototype, 'getCart').mockResolvedValue(mockGetCartResultShippingModeSimple());
2255+
jest.spyOn(DefaultCartService.prototype, 'getPaymentAmount').mockResolvedValue(mockGetPaymentAmount);
2256+
jest.spyOn(DefaultPaymentService.prototype, 'createPayment').mockResolvedValue(mockGetPaymentResult);
2257+
jest.spyOn(DefaultCartService.prototype, 'addPayment').mockResolvedValue(mockGetCartResultShippingModeSimple());
2258+
jest.spyOn(FastifyContext, 'getProcessorUrlFromContext').mockReturnValue('http://127.0.0.1');
2259+
jest.spyOn(FastifyContext, 'getMerchantReturnUrlFromContext').mockReturnValue('http://127.0.0.1/checkout/result');
2260+
jest.spyOn(PaymentsApi.prototype, 'payments').mockResolvedValue(mockAdyenCreatePaymentResponse);
2261+
jest.spyOn(DefaultPaymentService.prototype, 'updatePayment').mockResolvedValue(mockGetPaymentResult);
2262+
});
2263+
2264+
test('uses gift card balance as amountPlanned when balance is less than cart amount', async () => {
2265+
// cartAmount = 150000 (mockGetPaymentAmount), balance = 5000 → use 5000
2266+
jest.spyOn(OrdersApi.prototype, 'getBalanceOfGiftCard').mockResolvedValue({
2267+
balance: { value: 5000, currency: 'USD' },
2268+
pspReference: 'BALANCE-PSP-1',
2269+
resultCode: 'Success',
2270+
});
2271+
2272+
const adyenPaymentService = new AdyenPaymentService(opts);
2273+
await adyenPaymentService.createPayment(giftCardPaymentOpts);
2274+
2275+
expect(DefaultPaymentService.prototype.createPayment).toHaveBeenCalledWith(
2276+
expect.objectContaining({
2277+
amountPlanned: expect.objectContaining({ centAmount: 5000, currencyCode: 'USD' }),
2278+
}),
2279+
);
2280+
});
2281+
2282+
test('caps amountPlanned at cart amount when gift card balance exceeds remaining cart amount', async () => {
2283+
// cartAmount = 150000 (mockGetPaymentAmount), balance = 200000 → cap at 150000
2284+
jest.spyOn(OrdersApi.prototype, 'getBalanceOfGiftCard').mockResolvedValue({
2285+
balance: { value: 200000, currency: 'USD' },
2286+
pspReference: 'BALANCE-PSP-2',
2287+
resultCode: 'Success',
2288+
});
2289+
2290+
const adyenPaymentService = new AdyenPaymentService(opts);
2291+
await adyenPaymentService.createPayment(giftCardPaymentOpts);
2292+
2293+
expect(DefaultPaymentService.prototype.createPayment).toHaveBeenCalledWith(
2294+
expect.objectContaining({
2295+
amountPlanned: expect.objectContaining({ centAmount: 150000, currencyCode: 'USD' }),
2296+
}),
2297+
);
2298+
});
2299+
2300+
test('falls back to cart amount when balance check returns no balance', async () => {
2301+
jest.spyOn(OrdersApi.prototype, 'getBalanceOfGiftCard').mockResolvedValue({
2302+
pspReference: 'BALANCE-PSP-3',
2303+
resultCode: 'NotEnoughBalance',
2304+
});
2305+
2306+
const adyenPaymentService = new AdyenPaymentService(opts);
2307+
await adyenPaymentService.createPayment(giftCardPaymentOpts);
2308+
2309+
expect(DefaultPaymentService.prototype.createPayment).toHaveBeenCalledWith(
2310+
expect.objectContaining({
2311+
amountPlanned: expect.objectContaining({ centAmount: 150000, currencyCode: 'USD' }),
2312+
}),
2313+
);
2314+
});
2315+
2316+
test('uses cart amount directly when payment is not a gift card split payment', async () => {
2317+
const cardPaymentOpts: { data: CreatePaymentRequestDTO } = {
2318+
data: { paymentMethod: { type: 'scheme' } as Record<string, string> },
2319+
};
2320+
2321+
const balanceSpy = jest.spyOn(OrdersApi.prototype, 'getBalanceOfGiftCard');
2322+
2323+
const adyenPaymentService = new AdyenPaymentService(opts);
2324+
await adyenPaymentService.createPayment(cardPaymentOpts);
2325+
2326+
expect(balanceSpy).not.toHaveBeenCalled();
2327+
expect(DefaultPaymentService.prototype.createPayment).toHaveBeenCalledWith(
2328+
expect.objectContaining({
2329+
amountPlanned: expect.objectContaining({ centAmount: 150000 }),
2330+
}),
2331+
);
2332+
});
22182333
});
22192334
});

0 commit comments

Comments
 (0)