Skip to content

Commit

Permalink
PICT-219: Cancel Payment via Payments API
Browse files Browse the repository at this point in the history
  • Loading branch information
NghiaDTr committed Jul 30, 2024
1 parent ddcf5cf commit 5db989f
Show file tree
Hide file tree
Showing 15 changed files with 583 additions and 100 deletions.
6 changes: 3 additions & 3 deletions processor/src/errors/mollie.error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { CTError, CTEnumErrors, CTErrorExtensionExtraInfo } from '../types/comme

// This is based on MollieApiError interface from Mollie's SDK
/* eslint-disable @typescript-eslint/no-explicit-any */
const getExtraInfo = ({ status, statusCode, links, title, field }: any): CTErrorExtensionExtraInfo => {
const orginalStatus = status || statusCode;
export const getExtraInfo = ({ status, statusCode, links, title, field }: any): CTErrorExtensionExtraInfo => {
const originalStatus = status || statusCode;
const extraInfo = Object.assign(
{},
orginalStatus && { originalStatusCode: orginalStatus },
originalStatus && { originalStatusCode: originalStatus },
links && { links },
title && { title },
field && { field },
Expand Down
19 changes: 10 additions & 9 deletions processor/src/mollie/refund.mollie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ export const createPaymentRefund = async (params: CreateParameters): Promise<Ref
let errorMessage;

if (error instanceof MollieApiError) {
errorMessage = `SCTM - createMolliePaymentRefund - Calling Mollie API - error: ${error.message}`;
errorMessage = `SCTM - createPaymentRefund - Calling Mollie API - error: ${error.message}`;
} else {
errorMessage = `SCTM - createMolliePaymentRefund - Calling Mollie API - Failed to create refund with unknown errors`;
errorMessage = `SCTM - createPaymentRefund - Calling Mollie API - Failed to create refund with unknown errors`;
}

logger.error(errorMessage, {
paymentId: params.paymentId,
molliePaymentId: params.paymentId,
error,
});

Expand All @@ -45,13 +45,14 @@ export const getPaymentRefund = async (refundId: string, params: GetParameters)
let errorMessage;

if (error instanceof MollieApiError) {
errorMessage = `getPaymentRefund - error: ${error.message}`;
errorMessage = `SCTM - getPaymentRefund - Calling Mollie API - error: ${error.message}`;
} else {
errorMessage = `getPaymentRefund - Failed to cancel the refund with unknown errors`;
errorMessage = `SCTM - getPaymentRefund - Calling Mollie API - Failed to cancel the refund with unknown errors`;
}

logger.error({
message: errorMessage,
logger.error(errorMessage, {
molliePaymentId: params.paymentId,
mollieRefundId: refundId,
error,
});

Expand All @@ -74,9 +75,9 @@ export const cancelPaymentRefund = async (refundId: string, params: CancelParame
let errorMessage;

if (error instanceof MollieApiError) {
errorMessage = `cancelPaymentRefund - error: ${error.message}`;
errorMessage = `SCTM - cancelPaymentRefund - Calling Mollie API - error: ${error.message}`;
} else {
errorMessage = `cancelPaymentRefund - Failed to cancel the refund with unknown errors`;
errorMessage = `SCTM - cancelPaymentRefund - Calling Mollie API - Failed to cancel the refund with unknown errors`;
}

logger.error(errorMessage, {
Expand Down
24 changes: 18 additions & 6 deletions processor/src/service/payment.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ControllerResponseType, DeterminePaymentActionType } from '../types/controller.types';
import { CancelRefundStatusText, ConnectorActions, CustomFields, PAY_LATER_ENUMS } from '../utils/constant.utils';
import { CancelStatusText, ConnectorActions, CustomFields, PAY_LATER_ENUMS } from '../utils/constant.utils';
import { List, Method, Payment as MPayment, PaymentMethod } from '@mollie/api-client';
import { logger } from '../utils/logger.utils';
import {
Expand Down Expand Up @@ -308,23 +308,30 @@ export const handlePaymentCancelRefund = async (ctPayment: Payment): Promise<Con
* Retrieves the payment cancel actions based on the provided pending refund transaction.
* Would be used for cancel a payment or cancel a refund
*
* @param {Transaction} pendingRefundTransaction - The pending refund transaction.
* @param {Transaction} transaction - The pending refund transaction.
* @return {Action[]} An array of actions including updating the transaction state and setting the transaction custom field value.
* @throws {CustomError} If the JSON string from the custom field cannot be parsed.
*/
export const getPaymentCancelActions = (transaction: Transaction, action: DeterminePaymentActionType) => {
const transactionCustomFieldName = CustomFields.paymentCancelReason;

let errorPrefix;
if (action === ConnectorActions.CancelPayment) {
errorPrefix = 'SCTM - handleCancelPayment';
} else if (action === ConnectorActions.CancelRefund) {
errorPrefix = 'SCTM - handleCancelRefund';
}

const transactionCustomFieldValue = parseStringToJsonObject(
pendingRefundTransaction.custom?.fields[transactionCustomFieldName],
transaction.custom?.fields[transactionCustomFieldName],
transactionCustomFieldName,
'SCTM - handleCancelRefund',
pendingRefundTransaction.id,
errorPrefix,
transaction.id,
);

const newTransactionCustomFieldValue = {
reasonText: transactionCustomFieldValue.reasonText,
statusText: CancelRefundStatusText,
statusText: CancelStatusText,
};

return [
Expand Down Expand Up @@ -352,6 +359,11 @@ export const handleCancelPayment = async (ctPayment: Payment): Promise<Controlle
molliePaymentId: molliePayment.id,
commerceToolsPaymentId: ctPayment.id,
});

throw new CustomError(
400,
`SCTM - handleCancelPayment - Payment is not cancelable, Mollie Payment ID: ${molliePayment.id}`,
);
}

await cancelPayment(molliePayment.id);
Expand Down
2 changes: 1 addition & 1 deletion processor/src/utils/constant.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ export const ErrorMessages = {

export const PAY_LATER_ENUMS = [PaymentMethod.klarnapaylater, PaymentMethod.klarnasliceit];

export const CancelRefundStatusText = 'Cancelled from shop side';
export const CancelStatusText = 'Cancelled from shop side';
15 changes: 4 additions & 11 deletions processor/src/utils/paymentAction.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const getTransactionGroups = (transactions: Transaction[]) => {
return groups;
};

const determineAction = (groups: ReturnType<typeof getTransactionGroups>, key?: string): DeterminePaymentActionType => {
const determineAction = (groups: ReturnType<typeof getTransactionGroups>): DeterminePaymentActionType => {
if (groups.initialCharge.length > 1) {
throw new CustomError(400, 'Only one transaction can be in "Initial" state at any time');
}
Expand All @@ -60,14 +60,7 @@ const determineAction = (groups: ReturnType<typeof getTransactionGroups>, key?:
);
}

if (groups.pendingCharge.length && !key) {
throw new CustomError(
400,
'Cannot create a Transaction in state "Pending". This state is reserved to indicate the transaction has been accepted by the payment service provider',
);
}

if ((key || groups.initialCharge.length === 1) && !groups.successCharge.length && !groups.pendingCharge.length) {
if (groups.initialCharge.length === 1 && !groups.successCharge.length && !groups.pendingCharge.length) {
return ConnectorActions.CreatePayment;
}

Expand Down Expand Up @@ -106,8 +99,8 @@ export const determinePaymentAction = (ctPayment?: Payment): DeterminePaymentAct
return ConnectorActions.GetPaymentMethods;
}

const { key, transactions } = ctPayment;
const { transactions } = ctPayment;
const groups = getTransactionGroups(transactions);

return determineAction(groups, key);
return determineAction(groups);
};
28 changes: 28 additions & 0 deletions processor/src/validators/payment.validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,31 @@ export const checkValidRefundTransactionForCancel = (ctPayment: CTPayment): bool
return true;
};

/**
* Checks if the given Commercetools Payment object has a valid success charge transaction.
*
* @param {CTPayment} ctPayment - The Commercetools Payment object to check.
* @return {true | CustomError} Returns true if the refund transaction is valid, otherwise exception.
*/
export const checkValidPendingAuthorizationTransaction = (ctPayment: CTPayment): boolean => {
const pendingAuthorizationTransaction = ctPayment.transactions.find(
(transaction) =>
transaction.type === CTTransactionType.Authorization && transaction.state === CTTransactionState.Pending,
);

if (!pendingAuthorizationTransaction?.interactionId) {
logger.error(
`SCTM - handleCancelPayment - Cannot get the Mollie payment ID from CommerceTools transaction, CommerceTools Transaction ID: ${pendingAuthorizationTransaction?.id}.`,
);
throw new CustomError(
400,
`SCTM - handleCancelPayment - Cannot get the Mollie payment ID from CommerceTools transaction, CommerceTools Transaction ID: ${pendingAuthorizationTransaction?.id}.`,
);
}

return true;
};

/**
* Checks whether the payment method specific parameters are present in the payment object
* Currently, only perform the check with two payment methods: applepay and creditcard
Expand Down Expand Up @@ -259,6 +284,9 @@ export const validateCommerceToolsPaymentPayload = (
case ConnectorActions.CreatePayment:
checkPaymentMethodInput(connectorAction, ctPayment);
break;
case ConnectorActions.CancelPayment:
checkValidPendingAuthorizationTransaction(ctPayment);
break;
case ConnectorActions.CreateRefund:
checkValidSuccessChargeTransaction(ctPayment);
checkValidRefundTransactionForCreate(ctPayment);
Expand Down
8 changes: 2 additions & 6 deletions processor/tests/controllers/payment.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@ import {
handlePaymentCancelRefund,
handleCreateRefund,
} from '../../src/service/payment.service';
import {
CancelRefundStatusText,
ConnectorActions,
CustomFields as CustomFieldName,
} from '../../src/utils/constant.utils';
import { CancelStatusText, ConnectorActions, CustomFields as CustomFieldName } from '../../src/utils/constant.utils';
import { validateCommerceToolsPaymentPayload } from '../../src/validators/payment.validators';

jest.mock('../../src/service/payment.service', () => ({
Expand Down Expand Up @@ -289,7 +285,7 @@ describe('Test payment.controller.ts', () => {

const transactionCustomFieldValue = JSON.stringify({
responseText: 'Manually cancelled',
statusText: CancelRefundStatusText,
statusText: CancelStatusText,
});

const handlePaymentCancelRefundResponse = {
Expand Down
78 changes: 78 additions & 0 deletions processor/tests/errors/mollie.error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { CTEnumErrors } from './../../src/types/commercetools.types';
import { jest, expect, describe, it } from '@jest/globals';
import { formatErrorResponse, getExtraInfo } from '../../src/errors/mollie.error';

describe('Test getExtraInfo', () => {
it('should return correct information', () => {
const input = {
status: 'Error',
statusCode: 400,
links: 'dummy link',
title: 'dummy title',
field: 'dummy field',
test: 'test',
};

const result = getExtraInfo(input);

expect(result).toEqual({
originalStatusCode: input.status,
field: input.field,
links: input.links,
title: input.title,
});
});
});

describe('Test formatErrorResponse', () => {
jest.spyOn(require('../../src/errors/mollie.error'), 'getExtraInfo');

it('should return syntax error', () => {
const error = {
statusCode: 400,
message: 'Something wrong happened',
};

const result = formatErrorResponse(error);

expect(getExtraInfo).toBeCalledTimes(1);
expect(getExtraInfo).toBeCalledWith(error);

expect(result).toEqual({
status: 400,
errors: [
{
code: CTEnumErrors.SyntaxError,
message: error.message,
extensionExtraInfo: {
originalStatusCode: error.statusCode,
},
},
],
});
});

it('should return general error', () => {
const error = {
statusCode: 500,
};

const result = formatErrorResponse(error);

expect(getExtraInfo).toBeCalledTimes(1);
expect(getExtraInfo).toBeCalledWith(error);

expect(result).toEqual({
status: 400,
errors: [
{
code: CTEnumErrors.General,
message: 'Please see logs for more details',
extensionExtraInfo: {
originalStatusCode: error.statusCode,
},
},
],
});
});
});
32 changes: 16 additions & 16 deletions processor/tests/mollie/payment.mollie.spec.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
import { jest, expect, describe, it, afterEach } from '@jest/globals';
import { cancelPayment, createMolliePayment, getPaymentById, listPaymentMethods } from '../../src/mollie/payment.mollie';
import {
cancelPayment,
createMolliePayment,
getPaymentById,
listPaymentMethods,
} from '../../src/mollie/payment.mollie';
import { MollieApiError, PaymentCreateParams } from '@mollie/api-client';
import { logger } from '../../src/utils/logger.utils';
import CustomError from '../../src/errors/custom.error';

const mockPaymentsCreate = jest.fn();
const mockPaymentsGet = jest.fn();
const mockPaymentsList = jest.fn();
const mockPaymentRefundGet = jest.fn();
const mockPaymentRefundCancel = jest.fn();
const mockPaymentCancel = jest.fn();

jest.mock('../../src/client/mollie.client', () => ({
initMollieClient: jest.fn(() => ({
payments: {
create: mockPaymentsCreate,
get: mockPaymentsGet,
cancel: mockPaymentCancel,
},
methods: {
list: mockPaymentsList,
},
paymentRefunds: {
get: mockPaymentRefundGet,
cancel: mockPaymentRefundCancel,
},
})),
}));

Expand Down Expand Up @@ -198,18 +199,18 @@ describe('cancelPayment', () => {
jest.clearAllMocks(); // Clear all mocks after each test
});

it('should call cancel refund with the correct parameters', async () => {
it('should call cancel payment with the correct parameters', async () => {
await cancelPayment('tr_WDqYK6vllg');

expect(mockPaymentRefundCancel).toHaveBeenCalledTimes(1);
expect(mockPaymentRefundCancel).toHaveBeenCalledWith('tr_WDqYK6vllg');
expect(mockPaymentCancel).toHaveBeenCalledTimes(1);
expect(mockPaymentCancel).toHaveBeenCalledWith('tr_WDqYK6vllg');
});

it('should be able to return a proper error message when error which is an instance of MollieApiError occurred', async () => {
const errorMessage = 'Something wrong happened';
const mollieApiError = new MollieApiError(errorMessage);
const mollieApiError = new MollieApiError(errorMessage, { field: 'paymentId' });

(mockPaymentRefundCancel as jest.Mock).mockImplementation(() => {
(mockPaymentCancel as jest.Mock).mockImplementation(() => {
throw mollieApiError;
});

Expand All @@ -218,7 +219,7 @@ describe('cancelPayment', () => {
} catch (error: unknown) {
expect(error).toBeInstanceOf(CustomError);
expect(logger.error).toBeCalledTimes(1);
expect(logger.error).toBeCalledWith(`SCTM - cancelPayment - error: ${errorMessage}`,{
expect(logger.error).toBeCalledWith(`SCTM - cancelPayment - error: ${errorMessage}, field: paymentId`, {
error: mollieApiError,
});
}
Expand All @@ -227,7 +228,7 @@ describe('cancelPayment', () => {
it('should be able to return a proper error message when error which is not an instance of MollieApiError occurred', async () => {
const unexpectedError = new CustomError(400, 'dummy message');

(mockPaymentRefundCancel as jest.Mock).mockImplementation(() => {
(mockPaymentCancel as jest.Mock).mockImplementation(() => {
throw unexpectedError;
});

Expand All @@ -236,8 +237,7 @@ describe('cancelPayment', () => {
} catch (error: unknown) {
expect(error).toBeInstanceOf(CustomError);
expect(logger.error).toBeCalledTimes(1);
expect(logger.error).toBeCalledWith({
message: `SCTM - cancelPayment - Failed to cancel payments with unknown errors`,
expect(logger.error).toBeCalledWith('SCTM - cancelPayment - Failed to cancel payments with unknown errors', {
error: unexpectedError,
});
}
Expand Down
Loading

0 comments on commit 5db989f

Please sign in to comment.