From b54c210a909a6dc436ee4c6d6654421b2290f516 Mon Sep 17 00:00:00 2001 From: Martijn Date: Fri, 24 May 2024 16:46:09 +0200 Subject: [PATCH] fix(payments-plugin): Allow mollie orders with $0 (#2855) --- .../e2e/graphql/admin-queries.ts | 11 ++ .../e2e/graphql/shop-queries.ts | 37 +++-- .../payments-plugin/e2e/mollie-dev-server.ts | 69 +++------- .../e2e/mollie-payment.e2e-spec.ts | 41 +++++- .../payments-plugin/e2e/payment-helpers.ts | 126 ++++++++++++++++-- .../src/mollie/mollie.handler.ts | 13 +- .../src/mollie/mollie.service.ts | 46 +++++-- 7 files changed, 252 insertions(+), 91 deletions(-) diff --git a/packages/payments-plugin/e2e/graphql/admin-queries.ts b/packages/payments-plugin/e2e/graphql/admin-queries.ts index 0ac877e385..6b34ec9aa7 100644 --- a/packages/payments-plugin/e2e/graphql/admin-queries.ts +++ b/packages/payments-plugin/e2e/graphql/admin-queries.ts @@ -114,3 +114,14 @@ export const CREATE_CHANNEL = gql` } } `; + +export const CREATE_COUPON = gql` + mutation CreatePromotion($input: CreatePromotionInput!) { + createPromotion(input: $input) { + ... on ErrorResult { + errorCode + } + __typename + } + } +`; diff --git a/packages/payments-plugin/e2e/graphql/shop-queries.ts b/packages/payments-plugin/e2e/graphql/shop-queries.ts index 2d0879cdea..85669fe918 100644 --- a/packages/payments-plugin/e2e/graphql/shop-queries.ts +++ b/packages/payments-plugin/e2e/graphql/shop-queries.ts @@ -50,6 +50,17 @@ export const TEST_ORDER_FRAGMENT = gql` type } } + shippingAddress { + fullName + company + streetLine1 + streetLine2 + city + province + postalCode + country + phoneNumber + } shippingLines { shippingMethod { id @@ -104,17 +115,7 @@ export const SET_SHIPPING_ADDRESS = gql` mutation SetShippingAddress($input: CreateAddressInput!) { setOrderShippingAddress(input: $input) { ... on Order { - shippingAddress { - fullName - company - streetLine1 - streetLine2 - city - province - postalCode - country - phoneNumber - } + ...TestOrderFragment } ... on ErrorResult { errorCode @@ -122,6 +123,7 @@ export const SET_SHIPPING_ADDRESS = gql` } } } + ${TEST_ORDER_FRAGMENT} `; export const GET_ELIGIBLE_SHIPPING_METHODS = gql` @@ -221,3 +223,16 @@ export const GET_ACTIVE_ORDER = gql` } ${TEST_ORDER_FRAGMENT} `; + +export const APPLY_COUPON_CODE = gql` + mutation ApplyCouponCode($couponCode: String!) { + applyCouponCode(couponCode: $couponCode) { + ...TestOrderFragment + ... on ErrorResult { + errorCode + message + } + } + } + ${TEST_ORDER_FRAGMENT} +`; diff --git a/packages/payments-plugin/e2e/mollie-dev-server.ts b/packages/payments-plugin/e2e/mollie-dev-server.ts index 00a0a6930f..3d3835d941 100644 --- a/packages/payments-plugin/e2e/mollie-dev-server.ts +++ b/packages/payments-plugin/e2e/mollie-dev-server.ts @@ -1,14 +1,6 @@ import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; -import { - ChannelService, - DefaultLogger, - DefaultSearchPlugin, - LogLevel, - mergeConfig, - RequestContext, -} from '@vendure/core'; +import { DefaultLogger, DefaultSearchPlugin, LogLevel, mergeConfig } from '@vendure/core'; import { createTestEnvironment, registerInitializer, SqljsInitializer, testConfig } from '@vendure/testing'; -import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; import gql from 'graphql-tag'; import localtunnel from 'localtunnel'; import path from 'path'; @@ -23,9 +15,13 @@ import { CreatePaymentMethodMutationVariables, LanguageCode, } from './graphql/generated-admin-types'; -import { AddItemToOrderMutation, AddItemToOrderMutationVariables } from './graphql/generated-shop-types'; -import { ADD_ITEM_TO_ORDER, ADJUST_ORDER_LINE } from './graphql/shop-queries'; -import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers'; +import { ADD_ITEM_TO_ORDER, APPLY_COUPON_CODE } from './graphql/shop-queries'; +import { + CREATE_MOLLIE_PAYMENT_INTENT, + createFixedDiscountCoupon, + createFreeShippingCoupon, + setShipping, +} from './payment-helpers'; /** * This should only be used to locally test the Mollie payment plugin @@ -99,50 +95,23 @@ async function runMollieDevServer() { }, }, ); - // Prepare order with 2 items + // Prepare a test order where the total is 0 await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test'); - // Add another item to the order - await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: 'T_4', - quantity: 1, - }); - await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: 'T_5', + await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: 'T_1', quantity: 1, }); await setShipping(shopClient); - // Create payment intent - // Create payment intent - const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, { - input: { - redirectUrl: `${tunnel.url}/admin/orders?filter=open&page=1`, - paymentMethodCode: 'mollie', - // molliePaymentMethodCode: 'klarnapaylater' - }, - }); - if (createMolliePaymentIntent.errorCode) { - throw createMolliePaymentIntent; - } - // 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); + // Comment out these lines if you want to test the payment flow via Mollie + await createFixedDiscountCoupon(adminClient, 156880, 'DISCOUNT_ORDER'); + await createFreeShippingCoupon(adminClient, 'FREE_SHIPPING'); + await shopClient.query(APPLY_COUPON_CODE, { couponCode: 'DISCOUNT_ORDER' }); + await shopClient.query(APPLY_COUPON_CODE, { couponCode: 'FREE_SHIPPING' }); - // 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', - }, - }); + // Create Payment Intent + const result = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, { input: {} }); // eslint-disable-next-line no-console - console.log('\x1b[41m', `Second payment link: ${secondIntent.url as string}`, '\x1b[0m'); + console.log('Payment intent result', result); } (async () => { diff --git a/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts b/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts index 6d54a7b6c8..f4e8fc7511 100644 --- a/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts +++ b/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts @@ -4,6 +4,7 @@ import { EventBus, LanguageCode, mergeConfig, + Order, OrderPlacedEvent, OrderService, RequestContext, @@ -44,10 +45,17 @@ import { GetOrderByCodeQueryVariables, TestOrderFragmentFragment, } from './graphql/generated-shop-types'; -import { ADD_ITEM_TO_ORDER, GET_ORDER_BY_CODE } from './graphql/shop-queries'; +import { + ADD_ITEM_TO_ORDER, + APPLY_COUPON_CODE, + GET_ACTIVE_ORDER, + GET_ORDER_BY_CODE, +} from './graphql/shop-queries'; import { addManualPayment, CREATE_MOLLIE_PAYMENT_INTENT, + createFixedDiscountCoupon, + createFreeShippingCoupon, GET_MOLLIE_PAYMENT_METHODS, refundOrderLine, setShipping, @@ -196,7 +204,7 @@ describe('Mollie payments', () => { authorizedAsOwnerOnly: false, channel: await server.app.get(ChannelService).getDefaultChannel(), }); - await server.app.get(OrderService).addSurchargeToOrder(ctx, 1, { + await server.app.get(OrderService).addSurchargeToOrder(ctx, order.id.replace('T_', ''), { description: 'Negative test surcharge', listPrice: SURCHARGE_AMOUNT, }); @@ -441,6 +449,34 @@ describe('Mollie payments', () => { expect(method.maximumAmount).toBeDefined(); expect(method.image).toBeDefined(); }); + + it('Transitions to PaymentSettled for orders with a total of $0', async () => { + await shopClient.asUserWithCredentials(customers[1].emailAddress, 'test'); + const { addItemToOrder } = await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: 'T_1', + quantity: 1, + }); + await setShipping(shopClient); + // Discount the order so it has a total of $0 + await createFixedDiscountCoupon(adminClient, 156880, 'DISCOUNT_ORDER'); + await createFreeShippingCoupon(adminClient, 'FREE_SHIPPING'); + await shopClient.query(APPLY_COUPON_CODE, { couponCode: 'DISCOUNT_ORDER' }); + await shopClient.query(APPLY_COUPON_CODE, { couponCode: 'FREE_SHIPPING' }); + // Create payment intent + const { createMolliePaymentIntent: intent } = await shopClient.query( + CREATE_MOLLIE_PAYMENT_INTENT, + { + input: { + paymentMethodCode: mockData.methodCode, + redirectUrl: 'https://my-storefront.io/order-confirmation', + }, + }, + ); + const { orderByCode } = await shopClient.query(GET_ORDER_BY_CODE, { code: addItemToOrder.code }); + expect(intent.url).toBe('https://my-storefront.io/order-confirmation'); + expect(orderByCode.totalWithTax).toBe(0); + expect(orderByCode.state).toBe('PaymentSettled'); + }); }); describe('Handle standard payment methods', () => { @@ -486,6 +522,7 @@ describe('Mollie payments', () => { body: JSON.stringify({ id: mockData.mollieOrderResponse.id }), headers: { 'Content-Type': 'application/json' }, }); + await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test'); const { orderByCode } = await shopClient.query( GET_ORDER_BY_CODE, { diff --git a/packages/payments-plugin/e2e/payment-helpers.ts b/packages/payments-plugin/e2e/payment-helpers.ts index 022ee3eb61..24a5ac971a 100644 --- a/packages/payments-plugin/e2e/payment-helpers.ts +++ b/packages/payments-plugin/e2e/payment-helpers.ts @@ -1,10 +1,21 @@ import { ID } from '@vendure/common/lib/shared-types'; -import { ChannelService, OrderService, PaymentService, RequestContext } from '@vendure/core'; +import { + ChannelService, + ErrorResult, + OrderService, + PaymentService, + RequestContext, + assertFound, +} from '@vendure/core'; import { SimpleGraphQLClient, TestServer } from '@vendure/testing'; import gql from 'graphql-tag'; -import { REFUND_ORDER } from './graphql/admin-queries'; -import { RefundFragment, RefundOrderMutation, RefundOrderMutationVariables } from './graphql/generated-admin-types'; +import { CREATE_COUPON, REFUND_ORDER } from './graphql/admin-queries'; +import { + RefundFragment, + RefundOrderMutation, + RefundOrderMutationVariables, +} from './graphql/generated-admin-types'; import { GetShippingMethodsQuery, SetShippingMethodMutation, @@ -21,7 +32,7 @@ import { } from './graphql/shop-queries'; export async function setShipping(shopClient: SimpleGraphQLClient): Promise { - await shopClient.query(SET_SHIPPING_ADDRESS, { + const { setOrderShippingAddress: order } = await shopClient.query(SET_SHIPPING_ADDRESS, { input: { fullName: 'name', streetLine1: '12 the street', @@ -33,9 +44,17 @@ export async function setShipping(shopClient: SimpleGraphQLClient): Promise( GET_ELIGIBLE_SHIPPING_METHODS, ); - await shopClient.query(SET_SHIPPING_METHOD, { - id: eligibleShippingMethods[1].id, - }); + if (!eligibleShippingMethods?.length) { + throw Error( + `No eligible shipping methods found for order '${String(order.code)}' with a total of '${String(order.totalWithTax)}'`, + ); + } + await shopClient.query( + SET_SHIPPING_METHOD, + { + id: eligibleShippingMethods[1].id, + }, + ); } export async function proceedToArrangingPayment(shopClient: SimpleGraphQLClient): Promise { @@ -78,18 +97,98 @@ export async function addManualPayment(server: TestServer, orderId: ID, amount: authorizedAsOwnerOnly: false, channel: await server.app.get(ChannelService).getDefaultChannel(), }); - const order = await server.app.get(OrderService).findOne(ctx, orderId); + const order = await assertFound(server.app.get(OrderService).findOne(ctx, orderId)); // tslint:disable-next-line:no-non-null-assertion - await server.app.get(PaymentService).createManualPayment(ctx, order!, amount, { + await server.app.get(PaymentService).createManualPayment(ctx, order, amount, { method: 'Gift card', - // tslint:disable-next-line:no-non-null-assertion - orderId: order!.id, + orderId: order.id, metadata: { bogus: 'test', }, }); } +/** + * Create a coupon with the given code and discount amount. + */ +export async function createFixedDiscountCoupon( + adminClient: SimpleGraphQLClient, + amount: number, + couponCode: string, +): Promise { + const { createPromotion } = await adminClient.query(CREATE_COUPON, { + input: { + conditions: [], + actions: [ + { + code: 'order_fixed_discount', + arguments: [ + { + name: 'discount', + value: String(amount), + }, + ], + }, + ], + couponCode, + startsAt: null, + endsAt: null, + perCustomerUsageLimit: null, + usageLimit: null, + enabled: true, + translations: [ + { + languageCode: 'en', + name: `Coupon ${couponCode}`, + description: '', + customFields: {}, + }, + ], + customFields: {}, + }, + }); + if (createPromotion.__typename === 'ErrorResult') { + throw new Error(`Error creating coupon: ${(createPromotion as ErrorResult).errorCode}`); + } +} +/** + * Create a coupon that discounts the shipping costs + */ +export async function createFreeShippingCoupon( + adminClient: SimpleGraphQLClient, + couponCode: string, +): Promise { + const { createPromotion } = await adminClient.query(CREATE_COUPON, { + input: { + conditions: [], + actions: [ + { + code: 'free_shipping', + arguments: [], + }, + ], + couponCode, + startsAt: null, + endsAt: null, + perCustomerUsageLimit: null, + usageLimit: null, + enabled: true, + translations: [ + { + languageCode: 'en', + name: `Coupon ${couponCode}`, + description: '', + customFields: {}, + }, + ], + customFields: {}, + }, + }); + if (createPromotion.__typename === 'ErrorResult') { + throw new Error(`Error creating coupon: ${(createPromotion as ErrorResult).errorCode}`); + } +} + export const CREATE_MOLLIE_PAYMENT_INTENT = gql` mutation createMolliePaymentIntent($input: MolliePaymentIntentInput!) { createMolliePaymentIntent(input: $input) { @@ -105,9 +204,10 @@ export const CREATE_MOLLIE_PAYMENT_INTENT = gql` `; export const CREATE_STRIPE_PAYMENT_INTENT = gql` - mutation createStripePaymentIntent{ + mutation createStripePaymentIntent { createStripePaymentIntent - }`; + } +`; export const GET_MOLLIE_PAYMENT_METHODS = gql` query molliePaymentMethods($input: MolliePaymentMethodsInput!) { diff --git a/packages/payments-plugin/src/mollie/mollie.handler.ts b/packages/payments-plugin/src/mollie/mollie.handler.ts index d976ebcc8d..0cd9d9dcfd 100644 --- a/packages/payments-plugin/src/mollie/mollie.handler.ts +++ b/packages/payments-plugin/src/mollie/mollie.handler.ts @@ -1,4 +1,9 @@ -import createMollieClient, { OrderEmbed, PaymentStatus, RefundStatus } from '@mollie/api-client'; +import createMollieClient, { + OrderEmbed, + PaymentStatus, + RefundStatus, + Order as MollieOrder, +} from '@mollie/api-client'; import { LanguageCode } from '@vendure/common/lib/generated-types'; import { CreatePaymentErrorResult, @@ -57,13 +62,13 @@ export const molliePaymentHandler = new PaymentMethodHandler({ init(injector) { mollieService = injector.get(MollieService); }, - createPayment: async ( + createPayment: ( ctx, order, - _amount, // Don't use this amount, but the amount from the metadata + _amount, // Don't use this amount, but the amount from the metadata, because that has the actual paid amount from Mollie args, metadata, - ): Promise => { + ): CreatePaymentResult | CreatePaymentErrorResult => { // Only Admins and internal calls should be allowed to settle and authorize payments if (ctx.apiType !== 'admin' && ctx.apiType !== 'custom') { throw Error(`CreatePayment is not allowed for apiType '${ctx.apiType}'`); diff --git a/packages/payments-plugin/src/mollie/mollie.service.ts b/packages/payments-plugin/src/mollie/mollie.service.ts index 3f63bd8aaf..8e323578ca 100644 --- a/packages/payments-plugin/src/mollie/mollie.service.ts +++ b/packages/payments-plugin/src/mollie/mollie.service.ts @@ -176,6 +176,28 @@ export class MollieService { } const alreadyPaid = totalCoveredByPayments(order); const amountToPay = order.totalWithTax - alreadyPaid; + if (amountToPay === 0) { + // The order can be transitioned to PaymentSettled, because the order has 0 left to pay + // Only admins can add payments, so we need an admin ctx + const adminCtx = new RequestContext({ + apiType: 'admin', + isAuthorized: true, + authorizedAsOwnerOnly: false, + channel: ctx.channel, + languageCode: ctx.languageCode, + }); + await this.addPayment( + adminCtx, + order, + amountToPay, + { method: 'Settled without Mollie' }, + paymentMethod.code, + 'Settled', + ); + return { + url: redirectUrl, + }; + } const orderInput: CreateParameters = { orderNumber: order.code, amount: toAmount(amountToPay, order.currencyCode), @@ -289,17 +311,18 @@ export class MollieService { ); return; } + const amount = amountToCents(mollieOrder.amount); if (mollieOrder.status === OrderStatus.expired) { // Expired is fine, a customer can retry the payment later return; } if (mollieOrder.status === OrderStatus.paid) { // Paid is only used by 1-step payments without Authorized state. This will settle immediately - await this.addPayment(ctx, order, mollieOrder, paymentMethod.code, 'Settled'); + await this.addPayment(ctx, order, amount, mollieOrder, paymentMethod.code, 'Settled'); return; } if (order.state === 'AddingItems' && mollieOrder.status === OrderStatus.authorized) { - order = await this.addPayment(ctx, order, mollieOrder, paymentMethod.code, 'Authorized'); + order = await this.addPayment(ctx, order, amount, mollieOrder, paymentMethod.code, 'Authorized'); if (autoCapture && mollieOrder.status === OrderStatus.authorized) { // Immediately capture payment if autoCapture is set Logger.info(`Auto capturing payment for order ${order.code}`, loggerCtx); @@ -327,7 +350,8 @@ export class MollieService { async addPayment( ctx: RequestContext, order: Order, - mollieOrder: MollieOrder, + amount: number, + mollieMetadata: Partial, paymentMethodCode: string, status: 'Authorized' | 'Settled', ): Promise { @@ -347,15 +371,15 @@ export class MollieService { const addPaymentToOrderResult = await this.orderService.addPaymentToOrder(ctx, order.id, { method: paymentMethodCode, metadata: { - amount: amountToCents(mollieOrder.amount), + amount, status, - orderId: mollieOrder.id, - mode: mollieOrder.mode, - method: mollieOrder.method, - profileId: mollieOrder.profileId, - settlementAmount: mollieOrder.amount, - authorizedAt: mollieOrder.authorizedAt, - paidAt: mollieOrder.paidAt, + orderId: mollieMetadata.id, + mode: mollieMetadata.mode, + method: mollieMetadata.method, + profileId: mollieMetadata.profileId, + settlementAmount: mollieMetadata.amount, + authorizedAt: mollieMetadata.authorizedAt, + paidAt: mollieMetadata.paidAt, }, }); if (!(addPaymentToOrderResult instanceof Order)) {