diff --git a/integration-tests/modules/__tests__/payment/payments.spec.ts b/integration-tests/modules/__tests__/payment/payments.spec.ts index 7f38839be4..3f4c72a237 100644 --- a/integration-tests/modules/__tests__/payment/payments.spec.ts +++ b/integration-tests/modules/__tests__/payment/payments.spec.ts @@ -1,4 +1,7 @@ -import { capturePaymentWorkflow } from "@medusajs/core-flows" +import { + capturePaymentWorkflow, + refundPaymentWorkflow, +} from "@medusajs/core-flows" import { LinkModuleUtils, ModuleRegistrationName, @@ -120,7 +123,7 @@ medusaIntegrationTestRunner({ ) }) - it("should capture a payment with custom amount", async () => { + it("should partially capture a payment", async () => { const paymentCollection = await paymentService.createPaymentCollections( { region_id: "test-region", @@ -176,6 +179,109 @@ medusaIntegrationTestRunner({ }) ) }) + + it("should refund a payment", async () => { + const paymentCollection = await paymentService.createPaymentCollections( + { + region_id: "test-region", + amount: 1000, + currency_code: "usd", + } + ) + + const paymentSession = await paymentService.createPaymentSession( + paymentCollection.id, + { + provider_id: "pp_system_default", + amount: 1000, + currency_code: "usd", + data: {}, + } + ) + + const payment = await paymentService.authorizePaymentSession( + paymentSession.id, + {} + ) + + await capturePaymentWorkflow(appContainer).run({ + input: { + payment_id: payment.id, + }, + throwOnError: false, + }) + + await refundPaymentWorkflow(appContainer).run({ + input: { + payment_id: payment.id, + }, + throwOnError: false, + }) + + const [refund] = await paymentService.listRefunds({ + payment_id: payment.id, + }) + + expect(refund).toEqual( + expect.objectContaining({ + id: expect.any(String), + payment: expect.objectContaining({ id: payment.id }), + amount: 1000, + }) + ) + }) + + it("should partially refund a payment", async () => { + const paymentCollection = await paymentService.createPaymentCollections( + { + region_id: "test-region", + amount: 1000, + currency_code: "usd", + } + ) + + const paymentSession = await paymentService.createPaymentSession( + paymentCollection.id, + { + provider_id: "pp_system_default", + amount: 1000, + currency_code: "usd", + data: {}, + } + ) + + const payment = await paymentService.authorizePaymentSession( + paymentSession.id, + {} + ) + + await capturePaymentWorkflow(appContainer).run({ + input: { + payment_id: payment.id, + }, + throwOnError: false, + }) + + await refundPaymentWorkflow(appContainer).run({ + input: { + payment_id: payment.id, + amount: 500, + }, + throwOnError: false, + }) + + const [refund] = await paymentService.listRefunds({ + payment_id: payment.id, + }) + + expect(refund).toEqual( + expect.objectContaining({ + id: expect.any(String), + payment: expect.objectContaining({ id: payment.id }), + amount: 500, + }) + ) + }) }) }, }) diff --git a/packages/core-flows/src/payment/steps/index.ts b/packages/core-flows/src/payment/steps/index.ts index b1bafced6d..ab1ba44e2f 100644 --- a/packages/core-flows/src/payment/steps/index.ts +++ b/packages/core-flows/src/payment/steps/index.ts @@ -1 +1,3 @@ export * from "./capture-payment" +export * from "./refund-payment" + diff --git a/packages/core-flows/src/payment/steps/refund-payment.ts b/packages/core-flows/src/payment/steps/refund-payment.ts new file mode 100644 index 0000000000..9c1ff1a3af --- /dev/null +++ b/packages/core-flows/src/payment/steps/refund-payment.ts @@ -0,0 +1,23 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPaymentModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +type StepInput = { + payment_id: string + created_by?: string + amount?: number +} + +export const refundPaymentStepId = "refund-payment-step" +export const refundPaymentStep = createStep( + refundPaymentStepId, + async (input: StepInput, { container }) => { + const paymentModule = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + const payment = await paymentModule.refundPayment(input) + + return new StepResponse(payment) + } +) diff --git a/packages/core-flows/src/payment/workflows/index.ts b/packages/core-flows/src/payment/workflows/index.ts index b1bafced6d..ab1ba44e2f 100644 --- a/packages/core-flows/src/payment/workflows/index.ts +++ b/packages/core-flows/src/payment/workflows/index.ts @@ -1 +1,3 @@ export * from "./capture-payment" +export * from "./refund-payment" + diff --git a/packages/core-flows/src/payment/workflows/refund-payment.ts b/packages/core-flows/src/payment/workflows/refund-payment.ts new file mode 100644 index 0000000000..cfe108051c --- /dev/null +++ b/packages/core-flows/src/payment/workflows/refund-payment.ts @@ -0,0 +1,17 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { refundPaymentStep } from "../steps/refund-payment" + +export const refundPaymentWorkflowId = "refund-payment-workflow" +export const refundPaymentWorkflow = createWorkflow( + refundPaymentWorkflowId, + ( + input: WorkflowData<{ + payment_id: string + created_by?: string + amount?: number + }> + ) => { + const payment = refundPaymentStep(input) + return payment + } +) diff --git a/packages/medusa/src/api-v2/admin/payments/[id]/refund/route.ts b/packages/medusa/src/api-v2/admin/payments/[id]/refund/route.ts new file mode 100644 index 0000000000..ff3e9a90a5 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/payments/[id]/refund/route.ts @@ -0,0 +1,42 @@ +import { refundPaymentWorkflow } from "@medusajs/core-flows" +import { Modules } from "@medusajs/modules-sdk" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" +import { defaultAdminPaymentFields } from "../../query-config" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const { id } = req.params + + const { errors } = await refundPaymentWorkflow(req.scope).run({ + input: { + payment_id: id, + created_by: req.auth?.actor_id, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const query = remoteQueryObjectFromString({ + entryPoint: Modules.PAYMENT, + variables: { id }, + fields: defaultAdminPaymentFields, + }) + + const [payment] = await remoteQuery(query) + + res.status(200).json({ payment }) +} diff --git a/packages/medusa/src/api-v2/admin/payments/middlewares.ts b/packages/medusa/src/api-v2/admin/payments/middlewares.ts index 8c98eb60ef..4ea181c249 100644 --- a/packages/medusa/src/api-v2/admin/payments/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/payments/middlewares.ts @@ -35,10 +35,9 @@ export const adminPaymentRoutesMiddlewares: MiddlewareRoute[] = [ matcher: "/admin/payments/:id/capture", middlewares: [], }, - // TODO: Add in follow-up PR - // { - // method: ["POST"], - // matcher: "/admin/payments/:id/refund", - // middlewares: [], - // }, + { + method: ["POST"], + matcher: "/admin/payments/:id/refund", + middlewares: [], + }, ] diff --git a/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts b/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts index 4e550df680..3b03fbbb47 100644 --- a/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts +++ b/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts @@ -642,23 +642,23 @@ moduleIntegrationTestRunner({ ) }) - // it("should throw if refund is greater than captured amount", async () => { - // await service.capturePayment({ - // amount: 50, - // payment_id: "pay-id-1", - // }) - // - // const error = await service - // .refundPayment({ - // amount: 100, - // payment_id: "pay-id-1", - // }) - // .catch((e) => e) - // - // expect(error.message).toEqual( - // "Refund amount for payment: pay-id-1 cannot be greater than the amount captured on the payment." - // ) - // }) + it("should throw if refund is greater than captured amount", async () => { + await service.capturePayment({ + amount: 50, + payment_id: "pay-id-1", + }) + + const error = await service + .refundPayment({ + amount: 100, + payment_id: "pay-id-1", + }) + .catch((e) => e) + + expect(error.message).toEqual( + "You cannot refund more than what is captured on the payment." + ) + }) }) describe("cancel", () => { diff --git a/packages/payment/src/migrations/Migration20240225134525.ts b/packages/payment/src/migrations/Migration20240225134525.ts index a8195f7491..6ab5c96031 100644 --- a/packages/payment/src/migrations/Migration20240225134525.ts +++ b/packages/payment/src/migrations/Migration20240225134525.ts @@ -6,7 +6,7 @@ export class Migration20240225134525 extends Migration { const paymentCollectionExists = await this.execute( `SELECT * FROM information_schema.tables where table_name = 'payment_collection' and table_schema = 'public';` ) - + if (paymentCollectionExists.length) { this.addSql(` ${generatePostgresAlterColummnIfExistStatement( @@ -39,6 +39,12 @@ export class Migration20240225134525 extends Migration { ALTER TABLE IF EXISTS "refund" ADD COLUMN IF NOT EXISTS "raw_amount" JSONB NOT NULL; ALTER TABLE IF EXISTS "refund" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL; + ALTER TABLE IF EXISTS "refund" ADD COLUMN IF NOT EXISTS "created_by" TEXT NULL; + ${generatePostgresAlterColummnIfExistStatement( + "refund", + ["reason"], + "DROP NOT NULL" + )} CREATE TABLE IF NOT EXISTS "capture" ( "id" TEXT NOT NULL, diff --git a/packages/payment/src/services/payment-module.ts b/packages/payment/src/services/payment-module.ts index d6019934ca..b906ade9a3 100644 --- a/packages/payment/src/services/payment-module.ts +++ b/packages/payment/src/services/payment-module.ts @@ -450,9 +450,9 @@ export default class PaymentModuleService< ) } - const capturedAmount = payment.captures.reduce((acc, next) => { - const bn = new BigNumber(next.raw_amount.value) - return acc.plus(bn) + const capturedAmount = payment.captures.reduce((captureAmount, next) => { + const amountAsBigNumber = new BigNumber(next.raw_amount.value) + return captureAmount.plus(amountAsBigNumber) }, BigNumber(0)) const authorizedAmount = BigNumber(payment.raw_amount.value) @@ -507,17 +507,29 @@ export default class PaymentModuleService< ): Promise { const payment = await this.paymentService_.retrieve( data.payment_id, - { select: ["id", "data", "provider_id"] }, + { + select: ["id", "data", "provider_id", "amount", "raw_amount"], + relations: ["captures.raw_amount"], + }, sharedContext ) - // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged - // if (payment.captured_amount < input.amount) { - // throw new MedusaError( - // MedusaError.Types.INVALID_DATA, - // `Refund amount for payment: ${payment.id} cannot be greater than the amount captured on the payment.` - // ) - // } + if (!data.amount) { + data.amount = payment.amount as number + } + + const capturedAmount = payment.captures.reduce((captureAmount, next) => { + const amountAsBigNumber = new BigNumber(next.raw_amount.value) + return captureAmount.plus(amountAsBigNumber) + }, BigNumber(0)) + const refundAmount = BigNumber(data.amount) + + if (capturedAmount.lt(refundAmount)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `You cannot refund more than what is captured on the payment.` + ) + } const paymentData = await this.paymentProviderService_.refundPayment( {