Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(Mollie): Prevent duplicate payments #2691

Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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';
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without this, this.injector.get(OrderStateMachine); in the Mollie service throws an error.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it throws an error if you use a deep import to this?

@vendure/core/dist/service/helpers/order-state-machine/order-state-machine

weird, but ok - it's worth exposing anyway.

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('hayden.zieme12@hotmail.com', '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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These types were broken, but there is no type check or linting on test files. (Ideally there are, but that requires quite some time to set up...)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah the e2e test suites could use a bit of maintenance re TS errors. Not a high prio though for me since the main function (telling us if we broke something) still works. But yeah I tend to fix errors I see whenever I touch an existing file.

>(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;
};
}
Copy link
Collaborator Author

@martijnvdbrug martijnvdbrug Feb 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a custom interface instead of a *-types.d.ts file, because that would also infer customFields.mollieOrderId in the Stripe, Paypal and braintree plugin.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, makes sense to encapsulate this implementation detail 👍


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