From de7ce2958856eba3995641a872f604bdb7cd62b5 Mon Sep 17 00:00:00 2001 From: dasanor Date: Fri, 17 Jan 2025 10:51:59 +0100 Subject: [PATCH] feat(payments): accept a merchant reference in the payload of the payment intents API --- processor/package-lock.json | 34 ++++++------ processor/package.json | 2 +- .../dtos/operations/payment-intents.dto.ts | 3 ++ .../src/services/abstract-payment.service.ts | 14 +++-- .../src/services/adyen-payment.service.ts | 34 +++++++++++- .../converters/cancel-payment.converter.ts | 2 +- .../converters/capture-payment.converter.ts | 4 +- .../converters/notification.converter.ts | 2 +- .../converters/refund-payment.converter.ts | 2 +- .../src/services/types/operation.type.ts | 3 ++ processor/src/services/types/service.type.ts | 2 +- .../services/adyen-payment.service.spec.ts | 5 +- .../converters/cancel.converter.spec.ts | 43 +++++++++++++++ .../converters/capture.converter.spec.ts | 54 ++++++++++++++++++- .../converters/notification.converter.spec.ts | 24 ++++----- .../converters/refund.converter.spec.ts | 53 ++++++++++++++++++ 16 files changed, 238 insertions(+), 43 deletions(-) create mode 100644 processor/test/services/converters/cancel.converter.spec.ts create mode 100644 processor/test/services/converters/refund.converter.spec.ts diff --git a/processor/package-lock.json b/processor/package-lock.json index 02354a1..5ef0bbf 100644 --- a/processor/package-lock.json +++ b/processor/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@adyen/api-library": "22.1.0", "@commercetools-backend/loggers": "22.38.1", - "@commercetools/connect-payments-sdk": "0.15.0", + "@commercetools/connect-payments-sdk": "0.16.0", "@fastify/autoload": "6.0.3", "@fastify/cors": "10.0.2", "@fastify/formbody": "8.0.2", @@ -822,13 +822,13 @@ } }, "node_modules/@commercetools/connect-payments-sdk": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@commercetools/connect-payments-sdk/-/connect-payments-sdk-0.15.0.tgz", - "integrity": "sha512-F+H/cDmfi6jNsBEY2xFFuQOyTVk2Y2vSVoGUtduJt3Un2exK9NpuM/I2FOb8Z6NZr1YtXWDoNrKkS82F/g2Pyw==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@commercetools/connect-payments-sdk/-/connect-payments-sdk-0.16.0.tgz", + "integrity": "sha512-7bZ2vxDbunkVBhcIH/Tfa/xHAqtgUlRrQI4WhmsX2qjuW7M6esNgYRWzFxg9eQ2K+51teE4oO/Vg8oOZeCTY2g==", "license": "ISC", "dependencies": { "@commercetools-backend/loggers": "22.38.1", - "@commercetools/platform-sdk": "8.0.0", + "@commercetools/platform-sdk": "8.1.0", "@commercetools/sdk-client-v2": "2.5.0", "jsonwebtoken": "9.0.2", "jwks-rsa": "3.1.0", @@ -853,16 +853,16 @@ } }, "node_modules/@commercetools/platform-sdk": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@commercetools/platform-sdk/-/platform-sdk-8.0.0.tgz", - "integrity": "sha512-jpDA3E2p00evwRf252+XtbZGa2RQ74yFHQxRgzWrOFPbIs0b19SPk5fDS8kHks7ssGpiJ+DTseAAEcj1ZDCUyQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@commercetools/platform-sdk/-/platform-sdk-8.1.0.tgz", + "integrity": "sha512-bXBPO+XhwupVusq+Pf/SpYLhtRpQJnT6IlayHVu0txX8PiKW5+pvG76VBXOUqV1rg+ytsnRWp9oYEmRlexKNqQ==", "license": "MIT", "dependencies": { "@commercetools/sdk-client-v2": "^3.0.0", "@commercetools/sdk-middleware-auth": "^7.0.0", "@commercetools/sdk-middleware-http": "^7.0.0", "@commercetools/sdk-middleware-logger": "^3.0.0", - "@commercetools/ts-client": "^3.0.0" + "@commercetools/ts-client": "^3.0.1" }, "engines": { "node": ">=18" @@ -8649,12 +8649,12 @@ } }, "@commercetools/connect-payments-sdk": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@commercetools/connect-payments-sdk/-/connect-payments-sdk-0.15.0.tgz", - "integrity": "sha512-F+H/cDmfi6jNsBEY2xFFuQOyTVk2Y2vSVoGUtduJt3Un2exK9NpuM/I2FOb8Z6NZr1YtXWDoNrKkS82F/g2Pyw==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@commercetools/connect-payments-sdk/-/connect-payments-sdk-0.16.0.tgz", + "integrity": "sha512-7bZ2vxDbunkVBhcIH/Tfa/xHAqtgUlRrQI4WhmsX2qjuW7M6esNgYRWzFxg9eQ2K+51teE4oO/Vg8oOZeCTY2g==", "requires": { "@commercetools-backend/loggers": "22.38.1", - "@commercetools/platform-sdk": "8.0.0", + "@commercetools/platform-sdk": "8.1.0", "@commercetools/sdk-client-v2": "2.5.0", "jsonwebtoken": "9.0.2", "jwks-rsa": "3.1.0", @@ -8678,15 +8678,15 @@ } }, "@commercetools/platform-sdk": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@commercetools/platform-sdk/-/platform-sdk-8.0.0.tgz", - "integrity": "sha512-jpDA3E2p00evwRf252+XtbZGa2RQ74yFHQxRgzWrOFPbIs0b19SPk5fDS8kHks7ssGpiJ+DTseAAEcj1ZDCUyQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@commercetools/platform-sdk/-/platform-sdk-8.1.0.tgz", + "integrity": "sha512-bXBPO+XhwupVusq+Pf/SpYLhtRpQJnT6IlayHVu0txX8PiKW5+pvG76VBXOUqV1rg+ytsnRWp9oYEmRlexKNqQ==", "requires": { "@commercetools/sdk-client-v2": "^3.0.0", "@commercetools/sdk-middleware-auth": "^7.0.0", "@commercetools/sdk-middleware-http": "^7.0.0", "@commercetools/sdk-middleware-logger": "^3.0.0", - "@commercetools/ts-client": "^3.0.0" + "@commercetools/ts-client": "^3.0.1" }, "dependencies": { "@commercetools/sdk-client-v2": { diff --git a/processor/package.json b/processor/package.json index bdbf3e4..c661b72 100644 --- a/processor/package.json +++ b/processor/package.json @@ -23,7 +23,7 @@ "dependencies": { "@adyen/api-library": "22.1.0", "@commercetools-backend/loggers": "22.38.1", - "@commercetools/connect-payments-sdk": "0.15.0", + "@commercetools/connect-payments-sdk": "0.16.0", "@fastify/autoload": "6.0.3", "@fastify/cors": "10.0.2", "@fastify/formbody": "8.0.2", diff --git a/processor/src/dtos/operations/payment-intents.dto.ts b/processor/src/dtos/operations/payment-intents.dto.ts index 6bb1d96..0c2f240 100644 --- a/processor/src/dtos/operations/payment-intents.dto.ts +++ b/processor/src/dtos/operations/payment-intents.dto.ts @@ -11,6 +11,7 @@ export const ActionCapturePaymentSchema = Type.Composite([ }), Type.Object({ amount: AmountSchema, + merchantReference: Type.Optional(Type.String()), }), ]); @@ -20,12 +21,14 @@ export const ActionRefundPaymentSchema = Type.Composite([ }), Type.Object({ amount: AmountSchema, + merchantReference: Type.Optional(Type.String()), }), ]); export const ActionCancelPaymentSchema = Type.Composite([ Type.Object({ action: Type.Literal('cancelPayment'), + merchantReference: Type.Optional(Type.String()), }), ]); diff --git a/processor/src/services/abstract-payment.service.ts b/processor/src/services/abstract-payment.service.ts index 9623ccb..8e59e31 100644 --- a/processor/src/services/abstract-payment.service.ts +++ b/processor/src/services/abstract-payment.service.ts @@ -106,7 +106,12 @@ export abstract class AbstractPaymentService { action: request.action, }); - const res = await this.processPaymentModification(updatedPayment, transactionType, requestAmount); + const res = await this.processPaymentModification( + updatedPayment, + transactionType, + requestAmount, + request.merchantReference, + ); updatedPayment = await this.ctPaymentService.updatePayment({ id: ctPayment.id, @@ -151,16 +156,17 @@ export abstract class AbstractPaymentService { payment: Payment, transactionType: string, requestAmount: AmountSchemaDTO, + merchantReference?: string, ) { switch (transactionType) { case 'CancelAuthorization': { - return await this.cancelPayment({ payment }); + return await this.cancelPayment({ payment, merchantReference }); } case 'Charge': { - return await this.capturePayment({ amount: requestAmount, payment }); + return await this.capturePayment({ amount: requestAmount, payment, merchantReference }); } case 'Refund': { - return await this.refundPayment({ amount: requestAmount, payment }); + return await this.refundPayment({ amount: requestAmount, payment, merchantReference }); } default: { throw new ErrorInvalidOperation(`Operation ${transactionType} not supported.`); diff --git a/processor/src/services/adyen-payment.service.ts b/processor/src/services/adyen-payment.service.ts index e9d7069..49f03c5 100644 --- a/processor/src/services/adyen-payment.service.ts +++ b/processor/src/services/adyen-payment.service.ts @@ -55,6 +55,7 @@ import { RefundPaymentConverter } from './converters/refund-payment.converter'; import { log } from '../libs/logger'; import { ApplePayPaymentSessionError, UnsupportedNotificationError } from '../errors/adyen-api.error'; import { fetch as undiciFetch, Agent, Dispatcher } from 'undici'; +import { NotificationUpdatePayment } from './types/service.type'; // eslint-disable-next-line @typescript-eslint/no-require-imports const packageJSON = require('../../package.json'); @@ -380,10 +381,11 @@ export class AdyenPaymentService extends AbstractPaymentService { log.info('Processing notification', { notification: JSON.stringify(opts.data) }); try { const updateData = this.notificationConverter.convert(opts); + const payment = await this.getPaymentFromNotification(updateData); for (const tx of updateData.transactions) { const updatedPayment = await this.ctPaymentService.updatePayment({ - id: updateData.id, + id: payment.id, pspReference: updateData.pspReference, transaction: tx, }); @@ -564,4 +566,34 @@ export class AdyenPaymentService extends AbstractPaymentService { } return redirectUrl.toString(); } + + /** + * Retrieves a payment instance from the notification data + * First, it tries to find the payment by the interfaceId (PSP reference) + * As a fallback, it tries to find the payment by the merchantReference which unless the merchant overrides it, it's the payment ID + * @param data + * @returns A payment instance + */ + private async getPaymentFromNotification(data: NotificationUpdatePayment): Promise { + const interfaceId = data.pspReference; + let payment!: Payment; + + if (interfaceId) { + const results = await this.ctPaymentService.findPaymentsByInterfaceId({ + interfaceId, + }); + + if (results.length > 0) { + payment = results[0]; + } + } + + if (!payment) { + return await this.ctPaymentService.getPayment({ + id: data.merchantReference, + }); + } + + return payment; + } } diff --git a/processor/src/services/converters/cancel-payment.converter.ts b/processor/src/services/converters/cancel-payment.converter.ts index f0d04d9..203c3b6 100644 --- a/processor/src/services/converters/cancel-payment.converter.ts +++ b/processor/src/services/converters/cancel-payment.converter.ts @@ -6,7 +6,7 @@ export class CancelPaymentConverter { public convertRequest(opts: CancelPaymentRequest): PaymentCancelRequest { return { merchantAccount: config.adyenMerchantAccount, - reference: opts.payment.id, + reference: opts.merchantReference || opts.payment.id, }; } } diff --git a/processor/src/services/converters/capture-payment.converter.ts b/processor/src/services/converters/capture-payment.converter.ts index 1d27e3e..727e38e 100644 --- a/processor/src/services/converters/capture-payment.converter.ts +++ b/processor/src/services/converters/capture-payment.converter.ts @@ -38,7 +38,7 @@ export class CapturePaymentConverter { return { merchantAccount: config.adyenMerchantAccount, - reference: opts.payment.id, + reference: opts.merchantReference || opts.payment.id, amount: { currency: opts.amount.currencyCode, value: CurrencyConverters.convertWithMapping({ @@ -47,7 +47,7 @@ export class CapturePaymentConverter { currencyCode: opts.amount.currencyCode, }), }, - lineItems: adyenLineItems, + ...(adyenLineItems && { lineItems: adyenLineItems }), }; } diff --git a/processor/src/services/converters/notification.converter.ts b/processor/src/services/converters/notification.converter.ts index 0514705..f927e0f 100644 --- a/processor/src/services/converters/notification.converter.ts +++ b/processor/src/services/converters/notification.converter.ts @@ -11,7 +11,7 @@ export class NotificationConverter { const item = opts.data.notificationItems[0].NotificationRequestItem; return { - id: item.merchantReference, + merchantReference: item.merchantReference, pspReference: item.originalReference || item.pspReference, paymentMethod: item.paymentMethod, transactions: this.populateTransactions(item), diff --git a/processor/src/services/converters/refund-payment.converter.ts b/processor/src/services/converters/refund-payment.converter.ts index 15e8aa7..26ff9a3 100644 --- a/processor/src/services/converters/refund-payment.converter.ts +++ b/processor/src/services/converters/refund-payment.converter.ts @@ -8,7 +8,7 @@ export class RefundPaymentConverter { public convertRequest(opts: RefundPaymentRequest): PaymentRefundRequest { return { merchantAccount: config.adyenMerchantAccount, - reference: opts.payment.id, + reference: opts.merchantReference || opts.payment.id, amount: { currency: opts.amount.currencyCode, value: CurrencyConverters.convertWithMapping({ diff --git a/processor/src/services/types/operation.type.ts b/processor/src/services/types/operation.type.ts index 581b27b..65a8a79 100644 --- a/processor/src/services/types/operation.type.ts +++ b/processor/src/services/types/operation.type.ts @@ -10,15 +10,18 @@ import { Payment } from '@commercetools/connect-payments-sdk'; export type CapturePaymentRequest = { amount: AmountSchemaDTO; payment: Payment; + merchantReference?: string; }; export type CancelPaymentRequest = { payment: Payment; + merchantReference?: string; }; export type RefundPaymentRequest = { amount: AmountSchemaDTO; payment: Payment; + merchantReference?: string; }; export type PaymentProviderModificationResponse = { diff --git a/processor/src/services/types/service.type.ts b/processor/src/services/types/service.type.ts index 3c9f18e..71b63fd 100644 --- a/processor/src/services/types/service.type.ts +++ b/processor/src/services/types/service.type.ts @@ -1,6 +1,6 @@ import { TransactionData } from '@commercetools/connect-payments-sdk'; export type NotificationUpdatePayment = { - id: string; + merchantReference: string; pspReference?: string; transactions: TransactionData[]; paymentMethod?: string; diff --git a/processor/test/services/adyen-payment.service.spec.ts b/processor/test/services/adyen-payment.service.spec.ts index 8fc0bec..9b4b45d 100644 --- a/processor/test/services/adyen-payment.service.spec.ts +++ b/processor/test/services/adyen-payment.service.spec.ts @@ -711,6 +711,9 @@ describe('adyen-payment.service', () => { ], }; + jest + .spyOn(DefaultPaymentService.prototype, 'findPaymentsByInterfaceId') + .mockResolvedValue([mockUpdatePaymentResult]); jest.spyOn(DefaultPaymentService.prototype, 'updatePayment').mockResolvedValue(mockUpdatePaymentResult); // When @@ -718,7 +721,7 @@ describe('adyen-payment.service', () => { // Then expect(DefaultPaymentService.prototype.updatePayment).toHaveBeenCalledWith({ - id: merchantReference, + id: '123456', pspReference, transaction: { amount: { diff --git a/processor/test/services/converters/cancel.converter.spec.ts b/processor/test/services/converters/cancel.converter.spec.ts new file mode 100644 index 0000000..074de15 --- /dev/null +++ b/processor/test/services/converters/cancel.converter.spec.ts @@ -0,0 +1,43 @@ +import { describe, test, expect } from '@jest/globals'; +import { mockGetPaymentResult } from '../../utils/mock-payment-data'; +import { config } from '../../../src/config/config'; +import { CancelPaymentConverter } from '../../../src/services/converters/cancel-payment.converter'; + +describe('cancel.converter', () => { + const converter = new CancelPaymentConverter(); + + test('convert with checkout merchant reference', async () => { + // Arrange + const payment = mockGetPaymentResult; + const data = { + payment, + }; + + // Act + const result = converter.convertRequest(data); + + // Assert + expect(result).toEqual({ + merchantAccount: config.adyenMerchantAccount, + reference: mockGetPaymentResult.id, + }); + }); + + test('convert with custom merchant reference', async () => { + // Arrange + const payment = mockGetPaymentResult; + const data = { + payment, + merchantReference: 'merchantReference', + }; + + // Act + const result = converter.convertRequest(data); + + // Assert + expect(result).toEqual({ + merchantAccount: config.adyenMerchantAccount, + reference: 'merchantReference', + }); + }); +}); diff --git a/processor/test/services/converters/capture.converter.spec.ts b/processor/test/services/converters/capture.converter.spec.ts index 69835b7..2640659 100644 --- a/processor/test/services/converters/capture.converter.spec.ts +++ b/processor/test/services/converters/capture.converter.spec.ts @@ -1,9 +1,61 @@ import { describe, test, expect } from '@jest/globals'; -import { METHODS_REQUIRE_LINE_ITEMS } from '../../../src/services/converters/capture-payment.converter'; +import { + CapturePaymentConverter, + METHODS_REQUIRE_LINE_ITEMS, +} from '../../../src/services/converters/capture-payment.converter'; +import { paymentSDK } from '../../../src/payment-sdk'; +import { mockGetPaymentResult } from '../../utils/mock-payment-data'; +import { config } from '../../../src/config/config'; describe('capture.converter', () => { + const converter = new CapturePaymentConverter(paymentSDK.ctCartService, paymentSDK.ctOrderService); test('METHODS_REQUIRE_LINE_ITEMS', () => { const expected = ['klarna', 'klarna_account', 'klarna_paynow', 'klarna_b2b']; expect(METHODS_REQUIRE_LINE_ITEMS).toEqual(expected); }); + + test('convert with checkout merchant reference', async () => { + // Arrange + const payment = mockGetPaymentResult; + const data = { + amount: mockGetPaymentResult.amountPlanned, + payment, + }; + + // Act + const result = await converter.convertRequest(data); + + // Assert + expect(result).toEqual({ + merchantAccount: config.adyenMerchantAccount, + reference: mockGetPaymentResult.id, + amount: { + currency: mockGetPaymentResult.amountPlanned.currencyCode, + value: mockGetPaymentResult.amountPlanned.centAmount, + }, + }); + }); + + test('convert with custom merchant reference', async () => { + // Arrange + const payment = mockGetPaymentResult; + const data = { + amount: mockGetPaymentResult.amountPlanned, + payment, + merchantReference: 'merchantReference', + }; + + // Act + const result = await converter.convertRequest(data); + + // Assert + expect(result).toEqual({ + merchantAccount: config.adyenMerchantAccount, + reference: 'merchantReference', + amount: { + currency: mockGetPaymentResult.amountPlanned.currencyCode, + value: mockGetPaymentResult.amountPlanned.centAmount, + }, + }); + }); }); diff --git a/processor/test/services/converters/notification.converter.spec.ts b/processor/test/services/converters/notification.converter.spec.ts index e2dbbd3..f91c7ef 100644 --- a/processor/test/services/converters/notification.converter.spec.ts +++ b/processor/test/services/converters/notification.converter.spec.ts @@ -43,7 +43,7 @@ describe('notification.converter', () => { // Assert expect(result).toEqual({ - id: merchantReference, + merchantReference, pspReference, paymentMethod, transactions: [ @@ -92,7 +92,7 @@ describe('notification.converter', () => { // Assert expect(result).toEqual({ - id: merchantReference, + merchantReference, pspReference, paymentMethod, transactions: [ @@ -150,7 +150,7 @@ describe('notification.converter', () => { // Assert expect(result).toEqual({ - id: merchantReference, + merchantReference, pspReference, paymentMethod, transactions: [ @@ -203,7 +203,7 @@ describe('notification.converter', () => { // Assert expect(result).toEqual({ - id: merchantReference, + merchantReference, pspReference, paymentMethod, transactions: [ @@ -256,7 +256,7 @@ describe('notification.converter', () => { // Assert expect(result).toEqual({ - id: merchantReference, + merchantReference, pspReference, paymentMethod, transactions: [ @@ -309,7 +309,7 @@ describe('notification.converter', () => { // Assert expect(result).toEqual({ - id: merchantReference, + merchantReference, pspReference, paymentMethod, transactions: [ @@ -362,7 +362,7 @@ describe('notification.converter', () => { // Assert expect(result).toEqual({ - id: merchantReference, + merchantReference, pspReference, paymentMethod, transactions: [ @@ -415,7 +415,7 @@ describe('notification.converter', () => { // Assert expect(result).toEqual({ - id: merchantReference, + merchantReference, pspReference, paymentMethod, transactions: [ @@ -468,7 +468,7 @@ describe('notification.converter', () => { // Assert expect(result).toEqual({ - id: merchantReference, + merchantReference, pspReference, paymentMethod, transactions: [ @@ -521,7 +521,7 @@ describe('notification.converter', () => { // Assert expect(result).toEqual({ - id: merchantReference, + merchantReference, pspReference, paymentMethod, transactions: [ @@ -574,7 +574,7 @@ describe('notification.converter', () => { // Assert expect(result).toEqual({ - id: merchantReference, + merchantReference, pspReference, paymentMethod, transactions: [ @@ -627,7 +627,7 @@ describe('notification.converter', () => { // Assert expect(result).toEqual({ - id: merchantReference, + merchantReference, pspReference, paymentMethod, transactions: [ diff --git a/processor/test/services/converters/refund.converter.spec.ts b/processor/test/services/converters/refund.converter.spec.ts new file mode 100644 index 0000000..5405a42 --- /dev/null +++ b/processor/test/services/converters/refund.converter.spec.ts @@ -0,0 +1,53 @@ +import { describe, test, expect } from '@jest/globals'; +import { mockGetPaymentResult } from '../../utils/mock-payment-data'; +import { config } from '../../../src/config/config'; +import { RefundPaymentConverter } from '../../../src/services/converters/refund-payment.converter'; + +describe('refund.converter', () => { + const converter = new RefundPaymentConverter(); + + test('convert with checkout merchant reference', async () => { + // Arrange + const payment = mockGetPaymentResult; + const data = { + amount: mockGetPaymentResult.amountPlanned, + payment, + }; + + // Act + const result = converter.convertRequest(data); + + // Assert + expect(result).toEqual({ + merchantAccount: config.adyenMerchantAccount, + reference: mockGetPaymentResult.id, + amount: { + currency: mockGetPaymentResult.amountPlanned.currencyCode, + value: mockGetPaymentResult.amountPlanned.centAmount, + }, + }); + }); + + test('convert with custom merchant reference', async () => { + // Arrange + const payment = mockGetPaymentResult; + const data = { + amount: mockGetPaymentResult.amountPlanned, + payment, + merchantReference: 'merchantReference', + }; + + // Act + const result = converter.convertRequest(data); + + // Assert + expect(result).toEqual({ + merchantAccount: config.adyenMerchantAccount, + reference: 'merchantReference', + amount: { + currency: mockGetPaymentResult.amountPlanned.currencyCode, + value: mockGetPaymentResult.amountPlanned.centAmount, + }, + }); + }); +});