Skip to content

Commit

Permalink
feat(payments-plugin): Prevent duplicate Mollie payments (#2691)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: MolliePlugin - A new mollieOrderId has been added in order to prevent duplicate payments in Mollie. This will require a DB migration to add the custom field to your DB schema.
  • Loading branch information
martijnvdbrug authored Mar 4, 2024
1 parent e0c0ae0 commit 34b61cd
Show file tree
Hide file tree
Showing 9 changed files with 427 additions and 90 deletions.
1 change: 1 addition & 0 deletions packages/core/src/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export * from './helpers/order-merger/order-merger';
export * from './helpers/order-modifier/order-modifier';
export * from './helpers/order-splitter/order-splitter';
export * from './helpers/order-state-machine/order-state';
export * from './helpers/order-state-machine/order-state-machine';
export * from './helpers/password-cipher/password-cipher';
export * from './helpers/payment-state-machine/payment-state';
export * from './helpers/product-price-applicator/product-price-applicator';
Expand Down
19 changes: 19 additions & 0 deletions packages/payments-plugin/e2e/graphql/shop-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,25 @@ export const ADD_ITEM_TO_ORDER = gql`
${TEST_ORDER_FRAGMENT}
`;

export const ADJUST_ORDER_LINE = gql`
mutation AdjustOrderLine($orderLineId: ID!, $quantity: Int!) {
adjustOrderLine(orderLineId: $orderLineId, quantity: $quantity) {
...TestOrderFragment
... on ErrorResult {
errorCode
message
}
... on InsufficientStockError {
quantityAvailable
order {
...TestOrderFragment
}
}
}
}
${TEST_ORDER_FRAGMENT}
`;

export const GET_ORDER_BY_CODE = gql`
query GetOrderByCode($code: String!) {
orderByCode(code: $code) {
Expand Down
37 changes: 27 additions & 10 deletions packages/payments-plugin/e2e/mollie-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ import {
LanguageCode,
} from './graphql/generated-admin-types';
import { AddItemToOrderMutation, AddItemToOrderMutationVariables } from './graphql/generated-shop-types';
import { ADD_ITEM_TO_ORDER } from './graphql/shop-queries';
import { ADD_ITEM_TO_ORDER, ADJUST_ORDER_LINE } from './graphql/shop-queries';
import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';

/**
* This should only be used to locally test the Mollie payment plugin
* Make sure you have `MOLLIE_APIKEY=test_xxxx` in your .env file
*/
/* eslint-disable @typescript-eslint/no-floating-promises */
async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
Expand Down Expand Up @@ -101,21 +102,19 @@ async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
},
},
);
// Prepare order for payment
// Prepare order with 2 items
await shopClient.asUserWithCredentials('[email protected]', 'test');
// Add another item to the order
await shopClient.query<AddItemToOrderMutation, AddItemToOrderMutationVariables>(ADD_ITEM_TO_ORDER, {
productVariantId: 'T_5',
productVariantId: 'T_4',
quantity: 1,
});
const ctx = new RequestContext({
apiType: 'admin',
isAuthorized: true,
authorizedAsOwnerOnly: false,
channel: await server.app.get(ChannelService).getDefaultChannel(),
await shopClient.query<AddItemToOrderMutation, AddItemToOrderMutationVariables>(ADD_ITEM_TO_ORDER, {
productVariantId: 'T_5',
quantity: 1,
});
await setShipping(shopClient);
// Add pre payment to order
const order = await server.app.get(OrderService).findOne(ctx, 1);
// Create payment intent
const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
input: {
redirectUrl: `${tunnel.url}/admin/orders?filter=open&page=1&dynamicRedirectUrl=true`,
Expand All @@ -128,6 +127,24 @@ async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
}
// eslint-disable-next-line no-console
console.log('\x1b[41m', `Mollie payment link: ${createMolliePaymentIntent.url as string}`, '\x1b[0m');

// Remove first orderLine
await shopClient.query(ADJUST_ORDER_LINE, {
orderLineId: 'T_1',
quantity: 0,
});
await setShipping(shopClient);

// Create another intent after Xs, should update the mollie order
await new Promise(resolve => setTimeout(resolve, 5000));
const { createMolliePaymentIntent: secondIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
input: {
redirectUrl: `${tunnel.url}/admin/orders?filter=open&page=1&dynamicRedirectUrl=true`,
paymentMethodCode: 'mollie',
},
});
// eslint-disable-next-line no-console
console.log('\x1b[41m', `Second payment link: ${secondIntent.url as string}`, '\x1b[0m');
}

(async () => {
Expand Down
112 changes: 86 additions & 26 deletions packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { OrderStatus } from '@mollie/api-client';
import { ChannelService, LanguageCode, mergeConfig, OrderService, RequestContext } from '@vendure/core';
import {
ChannelService,
EventBus,
LanguageCode,
mergeConfig,
OrderPlacedEvent,
OrderService,
RequestContext,
} from '@vendure/core';
import {
SettlePaymentMutation,
SettlePaymentMutationVariables,
Expand Down Expand Up @@ -69,6 +77,9 @@ const mockData = {
],
},
resource: 'order',
metadata: {
languageCode: 'nl',
},
mode: 'test',
method: 'test-method',
profileId: '123',
Expand Down Expand Up @@ -128,7 +139,7 @@ let order: TestOrderFragmentFragment;
let serverPort: number;
const SURCHARGE_AMOUNT = -20000;

describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
describe('Mollie payments with useDynamicRedirectUrl=false', () => {
beforeAll(async () => {
const devConfig = mergeConfig(testConfig(), {
plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
Expand Down Expand Up @@ -266,7 +277,7 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
},
},
);
expect(result.message).toContain('The following variants are out of stock');
expect(result.message).toContain('insufficient stock of Pinelab stickers');
// Set stock back to not tracking
({ updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
input: {
Expand Down Expand Up @@ -324,6 +335,42 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
});
});

it('Should update existing Mollie order', async () => {
// Should fetch the existing order from Mollie
nock('https://api.mollie.com/')
.get('/v2/orders/ord_mockId')
.reply(200, mockData.mollieOrderResponse);
// Should patch existing order
nock('https://api.mollie.com/')
.patch(`/v2/orders/${mockData.mollieOrderResponse.id}`)
.reply(200, mockData.mollieOrderResponse);
// Should patch existing order lines
let molliePatchRequest: any | undefined;
nock('https://api.mollie.com/')
.patch(`/v2/orders/${mockData.mollieOrderResponse.id}/lines`, body => {
molliePatchRequest = body;
return true;
})
.reply(200, mockData.mollieOrderResponse);
const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
input: {
paymentMethodCode: mockData.methodCode,
},
});
// We expect the patch request to add 3 order lines, because the mock response has 0 lines
expect(createMolliePaymentIntent.url).toBeDefined();
expect(molliePatchRequest.operations).toBeDefined();
expect(molliePatchRequest.operations[0].operation).toBe('add');
expect(molliePatchRequest.operations[0].data).toHaveProperty('name');
expect(molliePatchRequest.operations[0].data).toHaveProperty('quantity');
expect(molliePatchRequest.operations[0].data).toHaveProperty('unitPrice');
expect(molliePatchRequest.operations[0].data).toHaveProperty('totalAmount');
expect(molliePatchRequest.operations[0].data).toHaveProperty('vatRate');
expect(molliePatchRequest.operations[0].data).toHaveProperty('vatAmount');
expect(molliePatchRequest.operations[1].operation).toBe('add');
expect(molliePatchRequest.operations[2].operation).toBe('add');
});

it('Should get payment url with deducted amount if a payment is already made', async () => {
let mollieRequest: any | undefined;
nock('https://api.mollie.com/')
Expand Down Expand Up @@ -385,7 +432,15 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
expect(adminOrder.state).toBe('ArrangingPayment');
});

let orderPlacedEvent: OrderPlacedEvent | undefined;

it('Should place order after paying outstanding amount', async () => {
server.app
.get(EventBus)
.ofType(OrderPlacedEvent)
.subscribe(event => {
orderPlacedEvent = event;
});
nock('https://api.mollie.com/')
.get('/v2/orders/ord_mockId')
.reply(200, {
Expand All @@ -400,7 +455,7 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
headers: { 'Content-Type': 'application/json' },
});
const { orderByCode } = await shopClient.query<GetOrderByCode.Query, GetOrderByCode.Variables>(
const { orderByCode } = await shopClient.query<GetOrderByCodeQuery, GetOrderByCodeQueryVariables>(
GET_ORDER_BY_CODE,
{
code: order.code,
Expand All @@ -411,6 +466,11 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
expect(order.state).toBe('PaymentSettled');
});

it('Should have preserved original languageCode ', async () => {
// We've set the languageCode to 'nl' in the mock response's metadata
expect(orderPlacedEvent?.ctx.languageCode).toBe('nl');
});

it('Should have Mollie metadata on payment', async () => {
const {
order: { payments },
Expand All @@ -435,14 +495,14 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
order.lines[0].id,
1,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
order!.payments[1].id,
order!.payments![1].id,
SURCHARGE_AMOUNT,
);
expect(refund.state).toBe('Failed');
});

it('Should successfully refund the Mollie payment', async () => {
let mollieRequest;
let mollieRequest: any;
nock('https://api.mollie.com/')
.get('/v2/orders/ord_mockId?embed=payments')
.reply(200, mockData.mollieOrderResponse);
Expand Down Expand Up @@ -547,8 +607,8 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {

it('Should add an unusable Mollie paymentMethod (missing redirectUrl)', async () => {
const { createPaymentMethod } = await adminClient.query<
CreatePaymentMethod.Mutation,
CreatePaymentMethod.Variables
CreatePaymentMethodMutation,
CreatePaymentMethodMutationVariables
>(CREATE_PAYMENT_METHOD, {
input: {
code: mockData.methodCodeBroken,
Expand All @@ -575,13 +635,13 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {

it('Should prepare an order', async () => {
await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(
ADD_ITEM_TO_ORDER,
{
productVariantId: 'T_5',
quantity: 10,
},
);
const { addItemToOrder } = await shopClient.query<
AddItemToOrderMutation,
AddItemToOrderMutationVariables
>(ADD_ITEM_TO_ORDER, {
productVariantId: 'T_5',
quantity: 10,
});
order = addItemToOrder as TestOrderFragmentFragment;
// Add surcharge
const ctx = new RequestContext({
Expand Down Expand Up @@ -613,7 +673,7 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
});
});

describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
describe('Mollie payments with useDynamicRedirectUrl=true', () => {
beforeAll(async () => {
const devConfig = mergeConfig(testConfig(), {
plugins: [MolliePlugin.init({ vendureHost: mockData.host, useDynamicRedirectUrl: true })],
Expand All @@ -632,7 +692,7 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
await adminClient.asSuperAdmin();
({
customers: { items: customers },
} = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(GET_CUSTOMER_LIST, {
} = await adminClient.query<GetCustomerListQuery, GetCustomerListQueryVariables>(GET_CUSTOMER_LIST, {
options: {
take: 2,
},
Expand All @@ -654,13 +714,13 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {

it('Should prepare an order', async () => {
await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(
ADD_ITEM_TO_ORDER,
{
productVariantId: 'T_5',
quantity: 10,
},
);
const { addItemToOrder } = await shopClient.query<
AddItemToOrderMutation,
AddItemToOrderMutationVariables
>(ADD_ITEM_TO_ORDER, {
productVariantId: 'T_5',
quantity: 10,
});
order = addItemToOrder as TestOrderFragmentFragment;
// Add surcharge
const ctx = new RequestContext({
Expand All @@ -678,8 +738,8 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {

it('Should add a working Mollie paymentMethod without specifying redirectUrl', async () => {
const { createPaymentMethod } = await adminClient.query<
CreatePaymentMethod.Mutation,
CreatePaymentMethod.Variables
CreatePaymentMethodMutation,
CreatePaymentMethodMutationVariables
>(CREATE_PAYMENT_METHOD, {
input: {
code: mockData.methodCode,
Expand Down
16 changes: 16 additions & 0 deletions packages/payments-plugin/src/mollie/custom-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { CustomFieldConfig, Order, CustomOrderFields } from '@vendure/core';

export interface OrderWithMollieReference extends Order {
customFields: CustomOrderFields & {
mollieOrderId?: string;
};
}

export const orderCustomFields: CustomFieldConfig[] = [
{
name: 'mollieOrderId',
type: 'string',
internal: true,
nullable: true,
},
];
Loading

0 comments on commit 34b61cd

Please sign in to comment.