diff --git a/CHANGELOG.md b/CHANGELOG.md index aede09e..636cfb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,202 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## v1.2.0-alpha +## v1.2.1 + +Added + +- New custom field for transaction: `sctm_transaction_refund_for_mollie_payment` which would store the Mollie Payment ID that need to be refunded + +Fixes + +[Create Refund](./docs/CreateRefund.md) +- Handling the Refund Creation for the case that the Payment has more than one Success Charge transaction + - Changing the way to determine the Create Refund action: + - Before + ```Typescript + // processor/src/utils/paymentAction.utils.ts + + if (groups.successCharge.length === 1 && groups.initialRefund.length) { + return ConnectorActions.CreateRefund; + } + ``` + + - After + ```Typescript + // processor/src/utils/paymentAction.utils.ts + + if (groups.successCharge.length >= 1 && groups.initialRefund.length) { + return ConnectorActions.CreateRefund; + } + ``` + + - We are supporting to create the refund for the payment which has more than one Success Charge transactions + - By default, we will create the Refund for the latest Success Charge transaction. For example: + ```Typescript + // CommerceTools Payment + { + id: 'payment-id', + transactions: [ + { + type: 'Charge', + state: 'Success', + interactionId: 'tr_123456' // Mollie Payment ID + }, + { + type: 'Charge', + state: 'Success', + interactionId: 'tr_999999' // Mollie Payment ID + }, + { + type: 'Refund', + state: 'Initial', // Creating a Refund for the Mollie Payment tr_999999 + }, + ] + } + ``` + + - However, you can also specify the Mollie Payment ID (which stored in the `interactionId` of the Success Charge transaction) that you want to create a refund for by adding the Mollie Payment ID to the custom field `sctm_transaction_refund_for_mollie_payment` of the Initial Refund transaction. For example: + + ```Typescript + // CommerceTools Payment + { + id: 'payment-id', + transactions: [ + { + type: 'Charge', + state: 'Success', + interactionId: 'tr_123456' // Mollie Payment ID + }, + { + type: 'Charge', + state: 'Success', + interactionId: 'tr_999999' // Mollie Payment ID + }, + { + type: 'Refund', + state: 'Initial', + custom: { + type: { + ... + }, + fields: { + sctm_transaction_refund_for_mollie_payment: 'tr_123456' // Creating a Refund for the Mollie Payment tr_123456 + } + } + }, + ] + } + ``` + +[Cancel Refund](./docs/CancelPaymentRefund.md) +- Following the changes for creating refund, we also updated the handler for Refund Cancellation to match with the above changes + - Changing the way to determine the Cancel Refund action: + - Before + ```Typescript + // processor/src/utils/paymentAction.utils.ts + + if ( + groups.successCharge.length === 1 && + groups.pendingRefund.length === 1 && + groups.initialCancelAuthorization.length === 1 + ) { + return ConnectorActions.CancelRefund; + } + ``` + + - After + ```Typescript + // processor/src/utils/paymentAction.utils.ts + + if ( + groups.successCharge.length >= 1 && + groups.pendingRefund.length >= 1 && + groups.initialCancelAuthorization.length === 1 + ) { + return ConnectorActions.CancelRefund; + } + ``` + + - To support the old versions, we will create the cancellation for the latest Pending Refund transaction (which is a pending refund for the latest Success Charge transaction in that payment). For example: + ```Typescript + // CommerceTools Payment + { + id: 'payment-id', + transactions: [ + { + type: 'Charge', + state: 'Success', + interactionId: 'tr_123456' // Mollie Payment ID + }, + { + type: 'Charge', + state: 'Success', + interactionId: 'tr_999999' // Mollie Payment ID + }, + { + id: 'refund-transaction-1', + type: 'Refund', + state: 'Pending', + interactionId: 're_123456', // Mollie Refund ID + }, + { + id: 'refund-transaction-2', + type: 'Refund', + state: 'Pending', + interactionId: 're_999999', // Mollie Refund ID + }, + { + type: 'CancelAuthorization', + state: 'Initial' + // interactionId is not set + } + ] + } + + // In this case, this will be considered as a Cancellation request for the Pending Refund with id: refund-transaction-2 + ``` + __*Note:* The above solution is just for supporting the old versions and will be remove in the near future (in next versions). From this version, please follow the below solution.__ + + - However, to do it in a correct way, from this version, you should specify the Mollie Refund ID (which stored in the `interactionId` of the Pending Refund transaction) that you want to cancel by putting it in the `interactionId` of the Initial CancelAuthorization. For example: + ```Typescript + // CommerceTools Payment + { + id: 'payment-id', + transactions: [ + { + type: 'Charge', + state: 'Success', + interactionId: 'tr_123456' // Mollie Payment ID + }, + { + type: 'Charge', + state: 'Success', + interactionId: 'tr_999999' // Mollie Payment ID + }, + { + id: 'refund-transaction-1', + type: 'Refund', + state: 'Pending', + interactionId: 're_123456', // Mollie Refund ID + }, + { + id: 'refund-transaction-2', + type: 'Refund', + state: 'Pending', + interactionId: 're_999999', // Mollie Refund ID + }, + { + type: 'CancelAuthorization', + state: 'Initial', + interactionId: 're_123456' // Mollie Refund ID that you want to cancel + } + ] + } + + // In this case, this will be considered as a Cancellation request for the Pending Refund with id: refund-transaction-1 + ``` + +## v1.2.0 Added diff --git a/docs/CancelPaymentRefund.md b/docs/CancelPaymentRefund.md index e5d9f42..13a6738 100644 --- a/docs/CancelPaymentRefund.md +++ b/docs/CancelPaymentRefund.md @@ -3,6 +3,7 @@ * [Parameters map](#parameters-map) * [Representation: CT Payment](#representation-ct-payment) * [Creating CommerceTools actions from Mollie's response](#creating-commercetools-actions-from-mollies-response) + * [Update per version](#update-per-version) ## Overview This functionality is used to cancel the pending refund which means it is created but not complete yet. @@ -155,3 +156,8 @@ When order is successfully cancelled on Mollie, we update commercetools payment | `changeTransactionState` | `transactionId: , state: 'Failure'` | | `changeTransactionState` | `transactionId: , state: 'Success'` | | `setTransactionCustomType` | `transactionId: , type.key:sctm_payment_cancel_reason, fields: {reasonText: "cancellation reason", statusText: "cancelled from shop side"}` | + +## Update per version + +The function was updated at: +- [v1.2.1](../CHANGELOG.md#v121) \ No newline at end of file diff --git a/docs/CreateRefund.md b/docs/CreateRefund.md index 58067ed..6f7fbc8 100644 --- a/docs/CreateRefund.md +++ b/docs/CreateRefund.md @@ -4,6 +4,7 @@ * [Parameters map](#parameters-map) * [Representation: CommerceTools Payment](#representation-ct-payment) * [Creating commercetools actions from Mollie's response](#creating-commercetools-actions-from-mollies-response) + * [Update per version](#update-per-version) ## Overview @@ -24,8 +25,6 @@ A transaction with type "Refund" and state "Initial" triggers a refund. In commercetools, we have a Payment which has one Transaction. This maps to an order in mollie. The commercetools Payment's key is the mollie orderId, and the commercetools Transaction maps to the payment in mollie. -In commercetools, we have a Payment which has one Transaction. This maps to an order in mollie. The commercetools Payment's key is the mollie orderId, and the commercetools Transaction maps to the payment in mollie. - ``` { id: "c0887a2d-bfbf-4f77-8f3d-fc33fb4c0920", @@ -96,4 +95,9 @@ transactions: [ ] ``` -When the refund is completed, this transaction's state will be updated by the notifications module to "Success" or "Failure". \ No newline at end of file +When the refund is completed, this transaction's state will be updated by the notifications module to "Success" or "Failure". + +## Update per version + +The function was updated at: +- [v1.2.1](../CHANGELOG.md#v121) \ No newline at end of file diff --git a/processor/src/commercetools/customFields.commercetools.ts b/processor/src/commercetools/customFields.commercetools.ts index 7bd9e4e..0d1c507 100644 --- a/processor/src/commercetools/customFields.commercetools.ts +++ b/processor/src/commercetools/customFields.commercetools.ts @@ -333,7 +333,7 @@ export async function createTransactionSurchargeCustomType(): Promise { .types() .post({ body: { - key: CustomFields.createPayment.interfaceInteraction.key, + key: CustomFields.transactionSurchargeCost, name: { en: 'SCTM - Transaction surcharge amount', de: 'SCTM - Betrag des Transaktionszuschlags', @@ -379,3 +379,83 @@ export async function createTransactionSurchargeCustomType(): Promise { return; } } + +export async function createTransactionRefundForMolliePaymentCustomType(): Promise { + const apiRoot = createApiRoot(); + const customFields: FieldDefinition[] = [ + { + name: CustomFields.transactionRefundForMolliePayment, + label: { + en: 'Identify the Mollie payment which is being refunded', + de: 'Identifizieren Sie die Mollie-Zahlung, die zurückerstattet wird', + }, + required: false, + type: { + name: 'String', + }, + inputHint: 'MultiLine', + }, + ]; + + const { + body: { results: types }, + } = await apiRoot + .types() + .get({ + queryArgs: { + where: `key = "${CustomFields.transactionRefundForMolliePayment}"`, + }, + }) + .execute(); + + if (types.length <= 0) { + await apiRoot + .types() + .post({ + body: { + key: CustomFields.transactionRefundForMolliePayment, + name: { + en: 'Identify the Mollie payment which is being refunded', + de: 'Identifizieren Sie die Mollie-Zahlung, die zurückerstattet wird', + }, + resourceTypeIds: ['transaction'], + fieldDefinitions: customFields, + }, + }) + .execute(); + + return; + } + + const type = types[0]; + const definitions = type.fieldDefinitions; + + if (definitions.length > 0) { + const actions: TypeUpdateAction[] = []; + definitions.forEach((definition) => { + actions.push({ + action: 'removeFieldDefinition', + fieldName: definition.name, + }); + }); + customFields.forEach((field) => { + actions.push({ + action: 'addFieldDefinition', + fieldDefinition: field, + }); + }); + + await apiRoot + .types() + .withKey({ key: CustomFields.transactionRefundForMolliePayment }) + .post({ + body: { + version: type.version, + actions, + }, + }) + .execute(); + + return; + } +} diff --git a/processor/src/service/connector.service.ts b/processor/src/service/connector.service.ts index f611771..d3bc990 100644 --- a/processor/src/service/connector.service.ts +++ b/processor/src/service/connector.service.ts @@ -4,6 +4,7 @@ import { createCustomPaymentInterfaceInteractionType, createCustomPaymentTransactionCancelReasonType, createTransactionSurchargeCustomType, + createTransactionRefundForMolliePaymentCustomType, } from '../commercetools/customFields.commercetools'; export const createExtensionAndCustomFields = async (extensionUrl: string): Promise => { await createPaymentExtension(extensionUrl); @@ -11,6 +12,7 @@ export const createExtensionAndCustomFields = async (extensionUrl: string): Prom await createCustomPaymentInterfaceInteractionType(); await createCustomPaymentTransactionCancelReasonType(); await createTransactionSurchargeCustomType(); + await createTransactionRefundForMolliePaymentCustomType(); }; export const removeExtension = async (): Promise => { diff --git a/processor/src/service/payment.service.ts b/processor/src/service/payment.service.ts index c0f5cd7..7323a88 100644 --- a/processor/src/service/payment.service.ts +++ b/processor/src/service/payment.service.ts @@ -71,6 +71,7 @@ import { convertCentToEUR, parseStringToJsonObject, roundSurchargeAmountToCent, + sortTransactionsByLatestCreationTime, } from '../utils/app.utils'; import ApplePaySession from '@mollie/api-client/dist/types/src/data/applePaySession/ApplePaySession'; import { getMethodConfigObjects, getSingleMethodConfigObject } from '../commercetools/customObjects.commercetools'; @@ -85,14 +86,6 @@ import { removeCartMollieCustomLineItem } from './cart.service'; * @return {CustomMethod[]} - The validated and sorted payment methods. */ const validateAndSortMethods = (methods: CustomMethod[], configObjects: CustomObject[]): CustomMethod[] => { - methods.push({ - id: 'googlepay', - name: { 'en-GB': 'Google Pay' }, - description: { 'en-GB': '' }, - image: '', - order: 0, - }); - if (!configObjects.length) { return methods.filter( (method: CustomMethod) => SupportedPaymentMethods[method.id.toString() as SupportedPaymentMethods], @@ -422,10 +415,6 @@ export const handleCreatePayment = async (ctPayment: Payment): Promise => { - const successChargeTransaction = ctPayment.transactions.find( - (transaction) => transaction.type === CTTransactionType.Charge && transaction.state === CTTransactionState.Success, - ); + let successChargeTransaction; + const updateActions = [] as UpdateAction[]; const initialRefundTransaction = ctPayment.transactions.find( (transaction) => transaction.type === CTTransactionType.Refund && transaction.state === CTTransactionState.Initial, ); + if (initialRefundTransaction?.custom?.fields[CustomFields.transactionRefundForMolliePayment]) { + logger.debug('SCTM - handleCreateRefund - creating a refund with specific payment id'); + + successChargeTransaction = ctPayment.transactions.find( + (transaction) => + transaction.type === CTTransactionType.Charge && + transaction.state === CTTransactionState.Success && + transaction.interactionId === + initialRefundTransaction?.custom?.fields[CustomFields.transactionRefundForMolliePayment], + ); + } else { + logger.debug('SCTM - handleCreateRefund - creating a refund for the latest success charge transaction'); + + const latestTransactions = sortTransactionsByLatestCreationTime(ctPayment.transactions); + + successChargeTransaction = latestTransactions.find( + (transaction) => + transaction.type === CTTransactionType.Charge && transaction.state === CTTransactionState.Success, + ); + + updateActions.push( + setTransactionCustomType(initialRefundTransaction?.id as string, CustomFields.transactionRefundForMolliePayment, { + [CustomFields.transactionRefundForMolliePayment]: successChargeTransaction?.interactionId, + }), + ); + } + + if (!successChargeTransaction) { + throw new CustomError(400, 'SCTM - handleCreateRefund - Cannot find valid success charge transaction'); + } + const paymentCreateRefundParams: CreateParameters = { paymentId: successChargeTransaction?.interactionId as string, amount: makeMollieAmount(initialRefundTransaction?.amount as CentPrecisionMoney), @@ -545,12 +562,14 @@ export const handleCreateRefund = async (ctPayment: Payment): Promise => { - const successChargeTransaction = ctPayment.transactions.find( - (transaction) => transaction.type === CTTransactionType.Charge && transaction.state === CTTransactionState.Success, - ); - - const pendingRefundTransaction = ctPayment.transactions.find( - (transaction) => transaction.type === CTTransactionType.Refund && transaction.state === CTTransactionState.Pending, - ); + let pendingRefundTransaction: any; + let successChargeTransaction: any; const initialCancelAuthorization = ctPayment.transactions.find( (transaction) => transaction.type === CTTransactionType.CancelAuthorization && transaction.state === CTTransactionState.Initial, ); + if (initialCancelAuthorization?.interactionId) { + pendingRefundTransaction = ctPayment.transactions.find( + (transaction) => + transaction.type === CTTransactionType.Refund && + transaction.state === CTTransactionState.Pending && + transaction?.interactionId === initialCancelAuthorization.interactionId, + ) as Transaction; + + if (pendingRefundTransaction) { + successChargeTransaction = ctPayment.transactions.find( + (transaction) => + transaction.type === CTTransactionType.Charge && + transaction.state === CTTransactionState.Success && + transaction.interactionId === + pendingRefundTransaction?.custom?.fields[CustomFields.transactionRefundForMolliePayment], + ) as Transaction; + } + + if (!successChargeTransaction) { + throw new CustomError( + 400, + 'SCTM - handlePaymentCancelRefund - Cannot find the valid Success Charge transaction.', + ); + } + } + + /** + * @deprecated v1.2 - Will be remove in the next version + */ + if (!pendingRefundTransaction || !successChargeTransaction) { + const latestTransactions = sortTransactionsByLatestCreationTime(ctPayment.transactions); + + pendingRefundTransaction = latestTransactions.find( + (transaction) => + transaction.type === CTTransactionType.Refund && transaction.state === CTTransactionState.Pending, + ); + + successChargeTransaction = latestTransactions.find( + (transaction) => + transaction.type === CTTransactionType.Charge && transaction.state === CTTransactionState.Success, + ); + } + /** + * end deprecated + */ + const paymentGetRefundParams: CancelParameters = { paymentId: successChargeTransaction?.interactionId as string, }; diff --git a/processor/src/utils/app.utils.ts b/processor/src/utils/app.utils.ts index 5c30a59..fbfe82e 100644 --- a/processor/src/utils/app.utils.ts +++ b/processor/src/utils/app.utils.ts @@ -1,5 +1,5 @@ import { SurchargeCost } from './../types/commercetools.types'; -import { Payment } from '@commercetools/platform-sdk'; +import { Payment, Transaction } from '@commercetools/platform-sdk'; import CustomError from '../errors/custom.error'; import { logger } from './logger.utils'; /** @@ -101,3 +101,22 @@ export const calculateTotalSurchargeAmount = (ctPayment: Payment, surcharges?: S export const roundSurchargeAmountToCent = (surchargeAmountInEur: number, fractionDigits: number): number => { return Math.round(surchargeAmountInEur * Math.pow(10, fractionDigits)); }; + +export const sortTransactionsByLatestCreationTime = (transactions: Transaction[]): Transaction[] => { + const clonedTransactions = Object.assign([], transactions); + + return clonedTransactions.sort((a: Transaction, b: Transaction) => { + const timeA = a.timestamp as string; + const timeB = b.timestamp as string; + + if (timeA < timeB) { + return 1; + } + + if (timeA > timeB) { + return -1; + } + + return 0; + }); +}; diff --git a/processor/src/utils/constant.utils.ts b/processor/src/utils/constant.utils.ts index 3e39cee..d060fdf 100644 --- a/processor/src/utils/constant.utils.ts +++ b/processor/src/utils/constant.utils.ts @@ -39,6 +39,7 @@ export const CustomFields = { }, }, transactionSurchargeCost: 'sctm_transaction_surcharge_cost', + transactionRefundForMolliePayment: 'sctm_transaction_refund_for_mollie_payment', }; export enum ConnectorActions { diff --git a/processor/src/utils/paymentAction.utils.ts b/processor/src/utils/paymentAction.utils.ts index cf8cc3e..03bbadc 100644 --- a/processor/src/utils/paymentAction.utils.ts +++ b/processor/src/utils/paymentAction.utils.ts @@ -68,13 +68,13 @@ const determineAction = (groups: ReturnType): Deter return ConnectorActions.CancelPayment; } - if (groups.successCharge.length === 1 && groups.initialRefund.length) { + if (groups.successCharge.length >= 1 && groups.initialRefund.length) { return ConnectorActions.CreateRefund; } if ( - groups.successCharge.length === 1 && - groups.pendingRefund.length === 1 && + groups.successCharge.length >= 1 && + groups.pendingRefund.length >= 1 && groups.initialCancelAuthorization.length === 1 ) { return ConnectorActions.CancelRefund; diff --git a/processor/tests/routes/processor.route.spec.ts b/processor/tests/routes/processor.route.spec.ts index d01c1d5..2e69811 100644 --- a/processor/tests/routes/processor.route.spec.ts +++ b/processor/tests/routes/processor.route.spec.ts @@ -8,6 +8,7 @@ import { createCustomPaymentInterfaceInteractionType, createCustomPaymentTransactionCancelReasonType, createTransactionSurchargeCustomType, + createTransactionRefundForMolliePaymentCustomType, } from '../../src/commercetools/customFields.commercetools'; jest.mock('../../src/commercetools/extensions.commercetools', () => ({ @@ -20,6 +21,7 @@ jest.mock('../../src/commercetools/customFields.commercetools', () => ({ createCustomPaymentInterfaceInteractionType: jest.fn(), createCustomPaymentTransactionCancelReasonType: jest.fn(), createTransactionSurchargeCustomType: jest.fn(), + createTransactionRefundForMolliePaymentCustomType: jest.fn(), })); describe('Test src/route/processor.route.ts', () => { @@ -112,6 +114,7 @@ describe('Test src/route/processor.route.ts', () => { (createCustomPaymentInterfaceInteractionType as jest.Mock).mockReturnValueOnce(Promise.resolve()); (createCustomPaymentTransactionCancelReasonType as jest.Mock).mockReturnValueOnce(Promise.resolve()); (createTransactionSurchargeCustomType as jest.Mock).mockReturnValueOnce(Promise.resolve()); + (createTransactionRefundForMolliePaymentCustomType as jest.Mock).mockReturnValueOnce(Promise.resolve()); req = { hostname: 'test.com', diff --git a/processor/tests/service/payment.service.spec.ts b/processor/tests/service/payment.service.spec.ts index 11d56f6..1aab09e 100644 --- a/processor/tests/service/payment.service.spec.ts +++ b/processor/tests/service/payment.service.spec.ts @@ -922,179 +922,6 @@ describe('Test listPaymentMethodsByPayment', () => { }); expect(JSON.stringify(response)).toContain('creditcard'); }); - - test('call listPaymentMethodsByPayment w/o custom objects', async () => { - (listPaymentMethods as jest.Mock).mockReturnValueOnce([ - { - resource: 'method', - id: 'paypal', - description: 'PayPal', - minimumAmount: { value: '0.01', currency: 'EUR' }, - maximumAmount: null, - image: { - size1x: 'https://www.mollie.com/external/icons/payment-methods/paypal.png', - size2x: 'https://www.mollie.com/external/icons/payment-methods/paypal%402x.png', - svg: 'https://www.mollie.com/external/icons/payment-methods/paypal.svg', - }, - status: 'activated', - _links: { - self: { - href: 'https://api.mollie.com/v2/methods/paypal', - type: 'application/hal+json', - }, - }, - }, - { - resource: 'method', - id: 'giftcard', - description: 'Geschenkkarten', - minimumAmount: { value: '0.01', currency: 'EUR' }, - maximumAmount: null, - image: { - size1x: 'https://www.mollie.com/external/icons/payment-methods/giftcard.png', - size2x: 'https://www.mollie.com/external/icons/payment-methods/giftcard%402x.png', - svg: 'https://www.mollie.com/external/icons/payment-methods/giftcard.svg', - }, - status: 'activated', - _links: { - self: { - href: 'https://api.mollie.com/v2/methods/giftcard', - type: 'application/hal+json', - }, - }, - }, - { - resource: 'method', - id: 'bancontact', - description: 'Bancontact', - minimumAmount: { value: '0.01', currency: 'EUR' }, - maximumAmount: null, - image: { - size1x: 'https://www.mollie.com/external/icons/payment-methods/bancontact.png', - size2x: 'https://www.mollie.com/external/icons/payment-methods/bancontact%402x.png', - svg: 'https://www.mollie.com/external/icons/payment-methods/bancontact.svg', - }, - status: 'activated', - _links: { - self: { - href: 'https://api.mollie.com/v2/methods/bancontact', - type: 'application/hal+json', - }, - }, - }, - { - resource: 'method', - id: 'banktransfer', - description: 'Bank transfer', - minimumAmount: { value: '0.01', currency: 'EUR' }, - maximumAmount: null, - image: { - size1x: 'https://www.mollie.com/external/icons/payment-methods/banktransfer.png', - size2x: 'https://www.mollie.com/external/icons/payment-methods/banktransfer%402x.png', - svg: 'https://www.mollie.com/external/icons/payment-methods/banktransfer.svg', - }, - status: 'activated', - _links: { - self: { - href: 'https://api.mollie.com/v2/methods/banktransfer', - type: 'application/hal+json', - }, - }, - }, - ]); - - (getMethodConfigObjects as jest.Mock).mockReturnValueOnce([]); - - mockResource = { - id: 'RANDOMID_12345', - paymentMethodInfo: { - paymentInterface: 'mollie', - method: 'card', - }, - amountPlanned: { - type: 'centPrecision', - currencyCode: 'EUR', - centAmount: 500000, - fractionDigits: 2, - }, - custom: { - fields: { - sctm_payment_methods_request: JSON.stringify({ - locale: 'de_DE', - billingCountry: 'DE', - }), - }, - } as unknown as CustomFields, - } as unknown as Payment; - - const response = await handleListPaymentMethodsByPayment(mockResource); - expect(response).toBeDefined(); - expect(response.statusCode).toBe(200); - expect(response?.actions?.length).toBeGreaterThan(0); - expect(response?.actions?.[0]?.action).toBe('setCustomField'); - expect((response?.actions?.[1] as any)?.value).toBe( - JSON.stringify({ - count: 5, - methods: [ - { - id: 'paypal', - name: { - 'en-GB': 'PayPal', - }, - description: { - 'en-GB': '', - }, - image: 'https://www.mollie.com/external/icons/payment-methods/paypal.svg', - order: 0, - }, - { - id: 'giftcard', - name: { - 'en-GB': 'Geschenkkarten', - }, - description: { - 'en-GB': '', - }, - image: 'https://www.mollie.com/external/icons/payment-methods/giftcard.svg', - order: 0, - }, - { - id: 'bancontact', - name: { - 'en-GB': 'Bancontact', - }, - description: { - 'en-GB': '', - }, - image: 'https://www.mollie.com/external/icons/payment-methods/bancontact.svg', - order: 0, - }, - { - id: 'banktransfer', - name: { - 'en-GB': 'Bank transfer', - }, - description: { - 'en-GB': '', - }, - image: 'https://www.mollie.com/external/icons/payment-methods/banktransfer.svg', - order: 0, - }, - { - id: 'googlepay', - name: { - 'en-GB': 'Google Pay', - }, - description: { - 'en-GB': '', - }, - image: '', - order: 0, - }, - ], - }), - ); - }); }); describe('Test getCreatePaymentUpdateAction', () => { @@ -1785,8 +1612,10 @@ describe('Test handleCreatePayment', () => { actions: ctActions, }); }); +}); - it('should return status code and array of actions', async () => { +describe('Test handleCreateRefund', () => { + it('should return status code and array of actions (1 success charge transaction)', async () => { const CTPayment: Payment = { id: '5c8b0375-305a-4f19-ae8e-07806b101999', version: 1, @@ -1801,8 +1630,20 @@ describe('Test handleCreatePayment', () => { paymentStatus: {}, transactions: [ { - id: '5c8b0375-305a-4f19-ae8e-07806b101999', - type: 'Authorization', + id: uuid, + type: 'Charge', + interactionId: 'tr_123123', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Success', + }, + { + id: 'test_refund', + type: 'Refund', amount: { type: 'centPrecision', currencyCode: 'EUR', @@ -1814,194 +1655,167 @@ describe('Test handleCreatePayment', () => { ], interfaceInteractions: [], paymentMethodInfo: { - method: 'googlepay', - }, - custom: { - type: { - typeId: 'type', - id: 'test', - }, - fields: { - sctm_payment_methods_request: JSON.stringify({ - billingCountry: 'DE', - }), - }, + method: 'creditcard', }, }; - const molliePayment: molliePayment = { - resource: 'payment', - id: 'tr_7UhSN1zuXS', + (changeTransactionState as jest.Mock).mockReturnValueOnce({ + action: 'changeTransactionState', + state: 'Pending', + transactionId: 'test_refund', + }); + + (createPaymentRefund as jest.Mock).mockReturnValue({ + id: 'fake_refund_id', + }); + + const paymentCreateRefundParams: CreateParameters = { + paymentId: 'tr_123123', amount: { value: '10.00', currency: 'EUR', }, - description: 'Order #12345', - redirectUrl: 'https://webshop.example.org/order/12345/', - webhookUrl: 'https://webshop.example.org/payments/webhook/', - metadata: '{"order_id":12345}', - profileId: 'pfl_QkEhN94Ba', - status: PaymentStatus.open, - isCancelable: false, - createdAt: '2024-03-20T09:13:37+00:00', - expiresAt: '2024-03-20T09:28:37+00:00', - _links: { - self: { - href: '...', - type: 'application/hal+json', - }, - checkout: { - href: 'https://www.mollie.com/checkout/select-method/7UhSN1zuXS', - type: 'text/html', + }; + + const result = await handleCreateRefund(CTPayment); + + expect(createPaymentRefund).toBeCalledTimes(1); + expect(createPaymentRefund).toBeCalledWith(paymentCreateRefundParams); + expect(result.statusCode).toBe(201); + expect(result.actions).toStrictEqual([ + { + action: 'setTransactionCustomType', + type: { + key: CustomFieldName.transactionRefundForMolliePayment, }, - documentation: { - href: '...', - type: 'text/html', + transactionId: 'test_refund', + fields: { + [CustomFieldName.transactionRefundForMolliePayment]: 'tr_123123', }, }, - } as molliePayment; - - const customLineItem = { - id: 'custom-line', - key: MOLLIE_SURCHARGE_CUSTOM_LINE_ITEM, - }; + { + action: 'changeTransactionInteractionId', + transactionId: 'test_refund', + interactionId: 'fake_refund_id', + }, + { + action: 'changeTransactionState', + transactionId: 'test_refund', + state: 'Pending', + }, + ]); + }); - const mockedCart = { - id: 'mocked-cart', - customLineItems: [customLineItem], - } as Cart; + it('should return status code and array of actions (more than 1 success charge transaction, with Mollie payment that need to be refunded is not specified)', async () => { + const targetedMolliePaymentId = 'tr_123456'; - const methodConfig = { - value: { - pricingConstraints: [ - { - currencyCode: CTPayment.amountPlanned.currencyCode, - countryCode: JSON.parse(CTPayment.custom?.fields?.sctm_payment_methods_request).billingCountry, - surchargeCost: { - percentageAmount: 2, - fixedAmount: 10, - }, + const CTPayment: Payment = { + id: '5c8b0375-305a-4f19-ae8e-07806b101999', + version: 1, + createdAt: '2024-07-04T14:07:35.625Z', + lastModifiedAt: '2024-07-04T14:07:35.625Z', + amountPlanned: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + paymentStatus: {}, + transactions: [ + { + id: uuid, + timestamp: '2024-06-24T08:28:43.474Z', + type: 'Charge', + interactionId: 'tr_123123', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, }, - ], + state: 'Success', + }, + { + id: 'test-123', + timestamp: '2024-06-24T08:30:43.474Z', + type: 'Charge', + interactionId: targetedMolliePaymentId, + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Success', + }, + { + id: 'test_refund', + type: 'Refund', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Initial', + }, + ], + interfaceInteractions: [], + paymentMethodInfo: { + method: 'creditcard', }, }; - const appUtils = require('../../src/utils/app.utils'); - - jest.spyOn(appUtils, 'calculateTotalSurchargeAmount'); - - const mapUtils = require('../../src/utils/map.utils'); - - jest.spyOn(mapUtils, 'createCartUpdateActions'); - - (getCartFromPayment as jest.Mock).mockReturnValue(mockedCart); - (getSingleMethodConfigObject as jest.Mock).mockReturnValueOnce(methodConfig); - (createMolliePayment as jest.Mock).mockReturnValueOnce(molliePayment); - (getPaymentExtension as jest.Mock).mockReturnValueOnce({ - destination: { - url: 'https://example.com', - }, - }); - - (createMollieCreatePaymentParams as jest.Mock).mockReturnValueOnce({ - method: 'googlepay', - }); - (changeTransactionState as jest.Mock).mockReturnValueOnce({ action: 'changeTransactionState', state: 'Pending', - transactionId: '5c8b0375-305a-4f19-ae8e-07806b101999', + transactionId: 'test_refund', }); - (updateCart as jest.Mock).mockReturnValue(mockedCart); - - const totalSurchargeAmount = 1020; - - const actual = await handleCreatePayment(CTPayment); + (createPaymentRefund as jest.Mock).mockReturnValue({ + id: 'fake_refund_id', + }); - const expectedCartUpdateActions = [ - { - action: 'removeCustomLineItem', - customLineItemId: customLineItem.id, - }, - { - action: 'addCustomLineItem', - name: { - de: MOLLIE_SURCHARGE_CUSTOM_LINE_ITEM, - en: MOLLIE_SURCHARGE_CUSTOM_LINE_ITEM, - }, - quantity: 1, - money: { - centAmount: totalSurchargeAmount, - currencyCode: CTPayment.amountPlanned.currencyCode, - }, - slug: MOLLIE_SURCHARGE_CUSTOM_LINE_ITEM, + const paymentCreateRefundParams: CreateParameters = { + paymentId: targetedMolliePaymentId, + amount: { + value: '10.00', + currency: 'EUR', }, - ]; - - expect(getSingleMethodConfigObject).toHaveBeenCalledWith(CTPayment.paymentMethodInfo.method); - expect(calculateTotalSurchargeAmount).toHaveBeenCalledTimes(1); - expect(calculateTotalSurchargeAmount).toHaveBeenCalledWith( - CTPayment, - methodConfig.value.pricingConstraints[0].surchargeCost, - ); - expect(calculateTotalSurchargeAmount).toHaveReturnedWith( - totalSurchargeAmount / Math.pow(10, CTPayment.amountPlanned.fractionDigits), - ); + }; - expect(createCartUpdateActions).toHaveBeenCalledTimes(1); - expect(createCartUpdateActions).toHaveBeenCalledWith(mockedCart, CTPayment, totalSurchargeAmount); - expect(createCartUpdateActions).toHaveReturnedWith(expectedCartUpdateActions); + const result = await handleCreateRefund(CTPayment); - const ctActions = [ + expect(createPaymentRefund).toBeCalledTimes(1); + expect(createPaymentRefund).toBeCalledWith(paymentCreateRefundParams); + expect(result.statusCode).toBe(201); + expect(result.actions).toStrictEqual([ { - action: 'addInterfaceInteraction', - type: { key: 'sctm_interface_interaction_type' }, + action: 'setTransactionCustomType', + type: { + key: CustomFieldName.transactionRefundForMolliePayment, + }, + transactionId: 'test_refund', fields: { - sctm_id: '5c8b0375-305a-4f19-ae8e-07806b101999', - sctm_action_type: 'createPayment', - sctm_created_at: '2024-03-20T09:13:37+00:00', - sctm_request: '{"transactionId":"5c8b0375-305a-4f19-ae8e-07806b101999","paymentMethod":"googlepay"}', - sctm_response: - '{"molliePaymentId":"tr_7UhSN1zuXS","checkoutUrl":"https://www.mollie.com/checkout/select-method/7UhSN1zuXS","transactionId":"5c8b0375-305a-4f19-ae8e-07806b101999"}', + [CustomFieldName.transactionRefundForMolliePayment]: targetedMolliePaymentId, }, }, { action: 'changeTransactionInteractionId', - transactionId: '5c8b0375-305a-4f19-ae8e-07806b101999', - interactionId: 'tr_7UhSN1zuXS', - }, - { - action: 'changeTransactionTimestamp', - transactionId: '5c8b0375-305a-4f19-ae8e-07806b101999', - timestamp: '2024-03-20T09:13:37+00:00', + transactionId: 'test_refund', + interactionId: 'fake_refund_id', }, { action: 'changeTransactionState', - transactionId: '5c8b0375-305a-4f19-ae8e-07806b101999', + transactionId: 'test_refund', state: 'Pending', }, - { - action: 'setTransactionCustomType', - type: { - key: 'sctm_transaction_surcharge_cost', - }, - fields: { - surchargeAmountInCent: 1020, - }, - transactionId: CTPayment.transactions[0].id, - }, - ]; - - expect(actual).toEqual({ - statusCode: 201, - actions: ctActions, - }); + ]); }); -}); -describe('Test handleCreateRefund', () => { - it('should return status code and array of actions', async () => { + it('should return status code and array of actions (more than 1 success charge transaction, with Mollie payment that need to be refunded is specified)', async () => { + const targetedMolliePaymentId = 'tr_123123'; + const CTPayment: Payment = { id: '5c8b0375-305a-4f19-ae8e-07806b101999', version: 1, @@ -2018,7 +1832,19 @@ describe('Test handleCreateRefund', () => { { id: uuid, type: 'Charge', - interactionId: 'tr_123123', + interactionId: targetedMolliePaymentId, + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Success', + }, + { + id: 'test-123', + type: 'Charge', + interactionId: 'tr_123456', amount: { type: 'centPrecision', currencyCode: 'EUR', @@ -2037,6 +1863,15 @@ describe('Test handleCreateRefund', () => { fractionDigits: 2, }, state: 'Initial', + custom: { + type: { + typeId: 'type', + id: 'custom-type-id', + }, + fields: { + [CustomFieldName.transactionRefundForMolliePayment]: targetedMolliePaymentId, + }, + }, }, ], interfaceInteractions: [], @@ -2056,7 +1891,7 @@ describe('Test handleCreateRefund', () => { }); const paymentCreateRefundParams: CreateParameters = { - paymentId: 'tr_123123', + paymentId: targetedMolliePaymentId, amount: { value: '10.00', currency: 'EUR', @@ -2237,7 +2072,6 @@ describe('Test handlePaymentCancelRefund', () => { { id: '5c8b0375-305a-4f19-ae8e-07806b102000', type: 'CancelAuthorization', - interactionId: 're_4qqhO89gsT', amount: { type: 'centPrecision', currencyCode: 'EUR', @@ -2312,7 +2146,7 @@ describe('Test handlePaymentCancelRefund', () => { } }); - it('should return status code and array of actions', async () => { + it('should return status code and array of actions (interactionId is not defined in the Initial CancelAuthorization transaction)', async () => { const mollieRefund: Refund = { resource: 'refund', id: CTPayment.transactions[1].interactionId, @@ -2358,6 +2192,297 @@ describe('Test handlePaymentCancelRefund', () => { paymentId: CTPayment.transactions[0].interactionId, }); }); + + it('should return status code and array of actions (interactionId is defined in the Initial CancelAuthorization transaction)', async () => { + const CTPaymentMocked: Payment = { + id: '5c8b0375-305a-4f19-ae8e-07806b101999', + version: 1, + createdAt: '2024-07-04T14:07:35.625Z', + lastModifiedAt: '2024-07-04T14:07:35.625Z', + amountPlanned: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + paymentStatus: {}, + transactions: [ + { + id: '5c8b0375-305a-4f19-ae8e-07806b101992', + type: 'Charge', + interactionId: 'tr_test', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Success', + }, + { + id: '5c8b0375-305a-4f19-ae8e-07806b101999', + type: 'Charge', + interactionId: 'tr_123123', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Success', + }, + { + id: '5c8b0375-305a-4f19-ae8e-07806b102011', + type: 'Refund', + interactionId: 're_TEST', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Pending', + }, + { + id: '5c8b0375-305a-4f19-ae8e-07806b102000', + type: 'Refund', + interactionId: 're_4qqhO89gsT', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Pending', + custom: { + type: { + typeId: 'type', + id: 'custom-type', + }, + fields: { + [CustomFieldName.transactionRefundForMolliePayment]: 'tr_123123', + }, + }, + }, + { + id: '5c8b0375-305a-4f19-ae8e-07806b102000', + type: 'CancelAuthorization', + interactionId: 're_4qqhO89gsT', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Initial', + custom: { + type: { + typeId: 'type', + id: 'sctm_payment_cancel_reason', + }, + fields: { + reasonText: 'dummy reason', + }, + }, + }, + ], + interfaceInteractions: [], + paymentMethodInfo: { + method: 'creditcard', + }, + }; + + const mollieRefund: Refund = { + resource: 'refund', + id: CTPaymentMocked.transactions[3].interactionId, + description: 'Order', + amount: { + currency: 'EUR', + value: '5.95', + }, + status: 'pending', + metadata: '{"bookkeeping_id":12345}', + paymentId: 'tr_7UhSN1zuXS', + createdAt: '2023-03-14T17:09:02.0Z', + _links: { + self: { + href: '...', + type: 'application/hal+json', + }, + payment: { + href: 'https://api.mollie.com/v2/payments/tr_7UhSN1zuXS', + type: 'application/hal+json', + }, + documentation: { + href: '...', + type: 'text/html', + }, + }, + } as Refund; + + (getPaymentRefund as jest.Mock).mockReturnValueOnce(mollieRefund); + + (cancelPaymentRefund as jest.Mock).mockReturnValueOnce(true); + + (getPaymentCancelActions as jest.Mock).mockReturnValueOnce([]); + + await handlePaymentCancelRefund(CTPaymentMocked); + + expect(getPaymentRefund).toBeCalledTimes(1); + expect(getPaymentRefund).toBeCalledWith(CTPaymentMocked.transactions[3].interactionId, { + paymentId: CTPaymentMocked.transactions[1].interactionId, + }); + expect(cancelPaymentRefund).toBeCalledTimes(1); + expect(cancelPaymentRefund).toBeCalledWith(CTPaymentMocked.transactions[3].interactionId, { + paymentId: CTPaymentMocked.transactions[1].interactionId, + }); + }); + + it('should throw error if valid Success Charge transaction was not found (interactionId is defined in the Initial CancelAuthorization transaction)', async () => { + const CTPaymentMocked: Payment = { + id: '5c8b0375-305a-4f19-ae8e-07806b101999', + version: 1, + createdAt: '2024-07-04T14:07:35.625Z', + lastModifiedAt: '2024-07-04T14:07:35.625Z', + amountPlanned: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + paymentStatus: {}, + transactions: [ + { + id: '5c8b0375-305a-4f19-ae8e-07806b101992', + type: 'Charge', + interactionId: 'tr_test123123', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Success', + }, + { + id: '5c8b0375-305a-4f19-ae8e-07806b101999', + type: 'Charge', + interactionId: 'tr_dummy', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Success', + }, + { + id: '5c8b0375-305a-4f19-ae8e-07806b102011', + type: 'Refund', + interactionId: 're_TEST', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Pending', + }, + { + id: '5c8b0375-305a-4f19-ae8e-07806b102000', + type: 'Refund', + interactionId: 're_4qqhO89gsT', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Pending', + custom: { + type: { + typeId: 'type', + id: 'custom-type', + }, + fields: { + [CustomFieldName.transactionRefundForMolliePayment]: 'tr_123123', + }, + }, + }, + { + id: '5c8b0375-305a-4f19-ae8e-07806b102000', + type: 'CancelAuthorization', + interactionId: 're_4qqhO89gsT', + amount: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1000, + fractionDigits: 2, + }, + state: 'Initial', + custom: { + type: { + typeId: 'type', + id: 'sctm_payment_cancel_reason', + }, + fields: { + reasonText: 'dummy reason', + }, + }, + }, + ], + interfaceInteractions: [], + paymentMethodInfo: { + method: 'creditcard', + }, + }; + + const mollieRefund: Refund = { + resource: 'refund', + id: CTPaymentMocked.transactions[3].interactionId, + description: 'Order', + amount: { + currency: 'EUR', + value: '5.95', + }, + status: 'pending', + metadata: '{"bookkeeping_id":12345}', + paymentId: 'tr_7UhSN1zuXS', + createdAt: '2023-03-14T17:09:02.0Z', + _links: { + self: { + href: '...', + type: 'application/hal+json', + }, + payment: { + href: 'https://api.mollie.com/v2/payments/tr_7UhSN1zuXS', + type: 'application/hal+json', + }, + documentation: { + href: '...', + type: 'text/html', + }, + }, + } as Refund; + + (getPaymentRefund as jest.Mock).mockReturnValueOnce(mollieRefund); + + (cancelPaymentRefund as jest.Mock).mockReturnValueOnce(true); + + (getPaymentCancelActions as jest.Mock).mockReturnValueOnce([]); + + try { + await handlePaymentCancelRefund(CTPaymentMocked); + } catch (error: any) { + expect(getPaymentRefund).toBeCalledTimes(0); + expect(cancelPaymentRefund).toBeCalledTimes(0); + + expect(error).toBeInstanceOf(CustomError); + expect((error as CustomError).message).toBe( + 'SCTM - handlePaymentCancelRefund - Cannot find the valid Success Charge transaction.', + ); + } + }); }); describe('Test handlePaymentWebhook', () => { diff --git a/processor/tests/utils/app.utils.spec.ts b/processor/tests/utils/app.utils.spec.ts index 60eda64..f76db6c 100644 --- a/processor/tests/utils/app.utils.spec.ts +++ b/processor/tests/utils/app.utils.spec.ts @@ -6,11 +6,12 @@ import { parseStringToJsonObject, removeEmptyProperties, roundSurchargeAmountToCent, + sortTransactionsByLatestCreationTime, validateEmail, } from '../../src/utils/app.utils'; import { logger } from '../../src/utils/logger.utils'; import CustomError from '../../src/errors/custom.error'; -import { Payment } from '@commercetools/platform-sdk'; +import { Payment, Transaction } from '@commercetools/platform-sdk'; import { SurchargeCost } from '../../src/types/commercetools.types'; describe('Test createDateNowString', () => { @@ -145,3 +146,63 @@ describe('Test roundSurchargeAmountToCent', () => { expect(roundSurchargeAmountToCent(surchargeAmountInEur, fractionDigits)).toBe(30100); }); }); + +describe('Test sortTransactionsByLatestCreationTime', () => { + it('should return the correct order', () => { + const data = [ + { + id: '39c1eae1-e9b4-45f0-ac18-7d83ec429cc8', + timestamp: '2024-06-24T08:28:43.474Z', + type: 'Authorization', + amount: { + type: 'centPrecision', + currencyCode: 'GBP', + centAmount: 61879, + fractionDigits: 2, + }, + interactionId: '12789fae-d6d6-4b66-9739-3a420dbda2a8', + state: 'Failure', + }, + { + id: '39c1eae1-e9b4-45f0-ac18-7d83ec429cde', + timestamp: '2024-06-24T08:29:43.474Z', + type: 'Authorization', + amount: { + type: 'centPrecision', + currencyCode: 'GBP', + centAmount: 61879, + fractionDigits: 2, + }, + interactionId: '12789fae-d6d6-4b66-9739-3a420dbda2a8', + state: 'Failure', + }, + { + id: '39c1eae1-e9b4-45f0-ac18-7d83ec429cd9', + timestamp: '2024-06-24T08:30:43.474Z', + type: 'Authorization', + amount: { + type: 'centPrecision', + currencyCode: 'GBP', + centAmount: 61879, + fractionDigits: 2, + }, + interactionId: '12789fae-d6d6-4b66-9739-3a420dbda2a8', + state: 'Failure', + }, + { + id: '39c1eae1-e9b4-45f0-ac18-7d83ec429111', + type: 'Authorization', + amount: { + type: 'centPrecision', + currencyCode: 'GBP', + centAmount: 61879, + fractionDigits: 2, + }, + interactionId: '12789fae-d6d6-4b66-9739-3a420dbda2a8', + state: 'Failure', + }, + ] as Transaction[]; + + expect(sortTransactionsByLatestCreationTime(data)).toStrictEqual([data[2], data[1], data[0], data[3]]); + }); +});