diff --git a/integration-tests/modules/__tests__/payment/payments.spec.ts b/integration-tests/modules/__tests__/payment/payments.spec.ts index 3c232d7cd1..7f38839be4 100644 --- a/integration-tests/modules/__tests__/payment/payments.spec.ts +++ b/integration-tests/modules/__tests__/payment/payments.spec.ts @@ -1,5 +1,10 @@ -import { ModuleRegistrationName, Modules } from "@medusajs/modules-sdk" -import { IRegionModuleService } from "@medusajs/types" +import { capturePaymentWorkflow } from "@medusajs/core-flows" +import { + LinkModuleUtils, + ModuleRegistrationName, + Modules, +} from "@medusajs/modules-sdk" +import { IPaymentModuleService, IRegionModuleService } from "@medusajs/types" import { medusaIntegrationTestRunner } from "medusa-test-utils/dist" jest.setTimeout(50000) @@ -12,14 +17,17 @@ medusaIntegrationTestRunner({ describe("Payments", () => { let appContainer let regionService: IRegionModuleService + let paymentService: IPaymentModuleService let remoteLink beforeAll(async () => { appContainer = getContainer() regionService = appContainer.resolve(ModuleRegistrationName.REGION) - remoteLink = appContainer.resolve("remoteLink") + paymentService = appContainer.resolve(ModuleRegistrationName.PAYMENT) + remoteLink = appContainer.resolve(LinkModuleUtils.REMOTE_LINK) }) + // TODO: Test should move to `integration-tests/api` it("should list payment providers", async () => { const region = await regionService.create({ name: "Test Region", @@ -55,6 +63,119 @@ medusaIntegrationTestRunner({ }), ]) }) + + it("should capture 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, + }) + + const [paymentResult] = await paymentService.listPayments({ + id: payment.id, + }) + + expect(paymentResult).toEqual( + expect.objectContaining({ + id: payment.id, + amount: 1000, + payment_collection_id: paymentCollection.id, + }) + ) + + const [capture] = await paymentService.listCaptures({ + payment_id: payment.id, + }) + + expect(capture).toEqual( + expect.objectContaining({ + id: expect.any(String), + payment: expect.objectContaining({ id: payment.id }), + amount: 1000, + }) + ) + }) + + it("should capture a payment with custom amount", 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, + amount: 500, + }, + throwOnError: false, + }) + + const [paymentResult] = await paymentService.listPayments({ + id: payment.id, + }) + + expect(paymentResult).toEqual( + expect.objectContaining({ + id: payment.id, + amount: 1000, + payment_collection_id: paymentCollection.id, + }) + ) + + const [capture] = await paymentService.listCaptures({ + payment_id: payment.id, + }) + + expect(capture).toEqual( + expect.objectContaining({ + id: expect.any(String), + payment: expect.objectContaining({ id: payment.id }), + amount: 500, + }) + ) + }) }) }, }) diff --git a/packages/core-flows/src/index.ts b/packages/core-flows/src/index.ts index 5577541f06..a04b6ef71a 100644 --- a/packages/core-flows/src/index.ts +++ b/packages/core-flows/src/index.ts @@ -1,13 +1,14 @@ +export * from "./api-key" export * from "./customer" export * from "./customer-group" export * from "./definition" export * from "./definitions" export * as Handlers from "./handlers" export * from "./invite" +export * from "./payment" +export * from "./product" export * from "./promotion" export * from "./region" -export * from "./user" -export * from "./tax" -export * from "./api-key" export * from "./store" -export * from "./product" +export * from "./tax" +export * from "./user" diff --git a/packages/core-flows/src/payment/index.ts b/packages/core-flows/src/payment/index.ts new file mode 100644 index 0000000000..68de82c9f9 --- /dev/null +++ b/packages/core-flows/src/payment/index.ts @@ -0,0 +1,2 @@ +export * from "./steps" +export * from "./workflows" diff --git a/packages/core-flows/src/payment/steps/capture-payment.ts b/packages/core-flows/src/payment/steps/capture-payment.ts new file mode 100644 index 0000000000..479dd5d990 --- /dev/null +++ b/packages/core-flows/src/payment/steps/capture-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 + captured_by?: string + amount?: number +} + +export const capturePaymentStepId = "capture-payment-step" +export const capturePaymentStep = createStep( + capturePaymentStepId, + async (input: StepInput, { container }) => { + const paymentModule = container.resolve( + ModuleRegistrationName.PAYMENT + ) + + const payment = await paymentModule.capturePayment(input) + + return new StepResponse(payment) + } +) diff --git a/packages/core-flows/src/payment/steps/index.ts b/packages/core-flows/src/payment/steps/index.ts new file mode 100644 index 0000000000..b1bafced6d --- /dev/null +++ b/packages/core-flows/src/payment/steps/index.ts @@ -0,0 +1 @@ +export * from "./capture-payment" diff --git a/packages/core-flows/src/payment/workflows/capture-payment.ts b/packages/core-flows/src/payment/workflows/capture-payment.ts new file mode 100644 index 0000000000..c303e93b4e --- /dev/null +++ b/packages/core-flows/src/payment/workflows/capture-payment.ts @@ -0,0 +1,18 @@ +import { PaymentDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { capturePaymentStep } from "../steps/capture-payment" + +export const capturePaymentWorkflowId = "capture-payment-workflow" +export const capturePaymentWorkflow = createWorkflow( + capturePaymentWorkflowId, + ( + input: WorkflowData<{ + payment_id: string + captured_by?: string + amount?: number + }> + ): WorkflowData => { + const payment = capturePaymentStep(input) + return payment + } +) diff --git a/packages/core-flows/src/payment/workflows/index.ts b/packages/core-flows/src/payment/workflows/index.ts new file mode 100644 index 0000000000..b1bafced6d --- /dev/null +++ b/packages/core-flows/src/payment/workflows/index.ts @@ -0,0 +1 @@ +export * from "./capture-payment" diff --git a/packages/medusa/src/api-v2/admin/payments/[id]/capture/route.ts b/packages/medusa/src/api-v2/admin/payments/[id]/capture/route.ts new file mode 100644 index 0000000000..aeba3ffd0d --- /dev/null +++ b/packages/medusa/src/api-v2/admin/payments/[id]/capture/route.ts @@ -0,0 +1,42 @@ +import { capturePaymentWorkflow } 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 capturePaymentWorkflow(req.scope).run({ + input: { + payment_id: id, + captured_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/[id]/route.ts b/packages/medusa/src/api-v2/admin/payments/[id]/route.ts new file mode 100644 index 0000000000..340539ec68 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/payments/[id]/route.ts @@ -0,0 +1,28 @@ +import { Modules } from "@medusajs/modules-sdk" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../types/routing" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const { id } = req.params + + const query = remoteQueryObjectFromString({ + entryPoint: Modules.PAYMENT, + variables: { id }, + fields: req.retrieveConfig.select as string[], + }) + + 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 new file mode 100644 index 0000000000..8c98eb60ef --- /dev/null +++ b/packages/medusa/src/api-v2/admin/payments/middlewares.ts @@ -0,0 +1,44 @@ +import { transformQuery } from "../../../api/middlewares" +import { MiddlewareRoute } from "../../../types/middlewares" +import { authenticate } from "../../../utils/authenticate-middleware" +import * as queryConfig from "./query-config" +import { AdminGetPaymentsParams } from "./validators" + +export const adminPaymentRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: "ALL", + matcher: "/admin/payments", + middlewares: [authenticate("admin", ["session", "bearer"])], + }, + { + method: ["GET"], + matcher: "/admin/payments", + middlewares: [ + transformQuery( + AdminGetPaymentsParams, + queryConfig.listTransformQueryConfig + ), + ], + }, + { + method: ["GET"], + matcher: "/admin/payments/:id", + middlewares: [ + transformQuery( + AdminGetPaymentsParams, + queryConfig.retrieveTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/payments/:id/capture", + middlewares: [], + }, + // TODO: Add in follow-up PR + // { + // method: ["POST"], + // matcher: "/admin/payments/:id/refund", + // middlewares: [], + // }, +] diff --git a/packages/medusa/src/api-v2/admin/payments/query-config.ts b/packages/medusa/src/api-v2/admin/payments/query-config.ts new file mode 100644 index 0000000000..e8bfeb2581 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/payments/query-config.ts @@ -0,0 +1,25 @@ +export const defaultAdminPaymentFields = [ + "id", + "currency_code", + "amount", + "payment_collection_id", + "payment_session_id", +] + +export const defaultAdminPaymentRelations = ["captures", "refunds"] + +export const allowedAdminPaymentRelations = ["captures", "refunds"] + +export const listTransformQueryConfig = { + defaultFields: defaultAdminPaymentFields, + defaultRelations: defaultAdminPaymentRelations, + allowedRelations: allowedAdminPaymentRelations, + isList: true, +} + +export const retrieveTransformQueryConfig = { + defaultFields: defaultAdminPaymentFields, + defaultRelations: defaultAdminPaymentRelations, + allowedRelations: allowedAdminPaymentRelations, + isList: false, +} diff --git a/packages/medusa/src/api-v2/admin/payments/route.ts b/packages/medusa/src/api-v2/admin/payments/route.ts new file mode 100644 index 0000000000..963a69f4e3 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/payments/route.ts @@ -0,0 +1,36 @@ +import { Modules } from "@medusajs/modules-sdk" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../types/routing" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const query = remoteQueryObjectFromString({ + entryPoint: Modules.PAYMENT, + variables: { + filters: req.filterableFields, + order: req.listConfig.order, + skip: req.listConfig.skip, + take: req.listConfig.take, + }, + fields: req.listConfig.select as string[], + }) + + const { rows: payments, metadata } = await remoteQuery(query) + + res.status(200).json({ + payments, + count: metadata.count, + offset: metadata.skip, + limit: metadata.take, + }) +} diff --git a/packages/medusa/src/api-v2/admin/payments/validators.ts b/packages/medusa/src/api-v2/admin/payments/validators.ts new file mode 100644 index 0000000000..73b66699ea --- /dev/null +++ b/packages/medusa/src/api-v2/admin/payments/validators.ts @@ -0,0 +1,46 @@ +import { Type } from "class-transformer" +import { IsOptional, ValidateNested } from "class-validator" +import { + DateComparisonOperator, + FindParams, + extendedFindParamsMixin, +} from "../../../types/common" +import { IsType } from "../../../utils" + +export class AdminGetPaymentsPaymentParams extends FindParams {} + +export class AdminGetPaymentsParams extends extendedFindParamsMixin({ + limit: 20, + offset: 0, +}) { + /** + * IDs to filter users by. + */ + @IsOptional() + @IsType([String, [String]]) + id?: string | string[] + + /** + * Date filters to apply on the users' `update_at` date. + */ + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + updated_at?: DateComparisonOperator + + /** + * Date filters to apply on the customer users' `created_at` date. + */ + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + created_at?: DateComparisonOperator + + /** + * Date filters to apply on the users' `deleted_at` date. + */ + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + deleted_at?: DateComparisonOperator +} diff --git a/packages/medusa/src/api-v2/middlewares.ts b/packages/medusa/src/api-v2/middlewares.ts index bf0434a01d..dd321eb8d3 100644 --- a/packages/medusa/src/api-v2/middlewares.ts +++ b/packages/medusa/src/api-v2/middlewares.ts @@ -5,6 +5,7 @@ import { adminCurrencyRoutesMiddlewares } from "./admin/currencies/middlewares" import { adminCustomerGroupRoutesMiddlewares } from "./admin/customer-groups/middlewares" import { adminCustomerRoutesMiddlewares } from "./admin/customers/middlewares" import { adminInviteRoutesMiddlewares } from "./admin/invites/middlewares" +import { adminPaymentRoutesMiddlewares } from "./admin/payments/middlewares" import { adminPriceListsRoutesMiddlewares } from "./admin/price-lists/middlewares" import { adminProductRoutesMiddlewares } from "./admin/products/middlewares" import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares" @@ -44,6 +45,7 @@ export const config: MiddlewaresConfig = { ...adminCurrencyRoutesMiddlewares, ...storeCurrencyRoutesMiddlewares, ...adminProductRoutesMiddlewares, + ...adminPaymentRoutesMiddlewares, ...adminPriceListsRoutesMiddlewares, ], } 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 6c88f06f24..4e550df680 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 @@ -444,7 +444,6 @@ moduleIntegrationTestRunner({ data: {}, cart_id: null, order_id: null, - order_edit_id: null, customer_id: null, deleted_at: null, captured_at: null, @@ -498,7 +497,7 @@ moduleIntegrationTestRunner({ }) describe("capture", () => { - it("should capture a payment successfully", async () => { + it("should capture a payment successfully and update captured_at", async () => { const capturedPayment = await service.capturePayment({ amount: 100, payment_id: "pay-id-1", @@ -515,61 +514,104 @@ moduleIntegrationTestRunner({ amount: 100, }), ], - - // TODO: uncomment when totals calculations are implemented - // captured_amount: 100, - // captured_at: expect.any(Date), + captured_at: expect.any(Date), }) ) }) - // TODO: uncomment when totals are implemented + it("should split a payment in two captures a payment successfully", async () => { + await service.capturePayment({ + amount: 50, + payment_id: "pay-id-1", + }) - // it("should fail to capture amount greater than authorized", async () => { - // const error = await service - // .capturePayment({ - // amount: 200, - // payment_id: "pay-id-1", - // }) - // .catch((e) => e) - // - // expect(error.message).toEqual( - // "Total captured amount for payment: pay-id-1 exceeds authorised amount." - // ) - // }) - // - // it("should fail to capture already captured payment", async () => { - // await service.capturePayment({ - // amount: 100, - // payment_id: "pay-id-1", - // }) - // - // const error = await service - // .capturePayment({ - // amount: 100, - // payment_id: "pay-id-1", - // }) - // .catch((e) => e) - // - // expect(error.message).toEqual( - // "The payment: pay-id-1 is already fully captured." - // ) - // }) - // - // it("should fail to capture a canceled payment", async () => { - // await service.cancelPayment("pay-id-1") - // - // const error = await service - // .capturePayment({ - // amount: 100, - // payment_id: "pay-id-1", - // }) - // .catch((e) => e) - // - // expect(error.message).toEqual( - // "The payment: pay-id-1 has been canceled." - // ) - // }) + const capturedPayment = await service.capturePayment({ + amount: 50, + payment_id: "pay-id-1", + }) + + expect(capturedPayment).toEqual( + expect.objectContaining({ + id: "pay-id-1", + amount: 100, + + captures: [ + expect.objectContaining({ + created_by: null, + amount: 50, + }), + expect.objectContaining({ + created_by: null, + amount: 50, + }), + ], + }) + ) + }) + + it("should fail to capture amount greater than authorized", async () => { + const error = await service + .capturePayment({ + amount: 200, + payment_id: "pay-id-1", + }) + .catch((e) => e) + + expect(error.message).toEqual( + "You cannot capture more than the authorized amount substracted by what is already captured." + ) + }) + + it("should fail to capture amount greater than what is already captured", async () => { + await service.capturePayment({ + amount: 99, + payment_id: "pay-id-1", + }) + + const error = await service + .capturePayment({ + amount: 2, + payment_id: "pay-id-1", + }) + .catch((e) => e) + + expect(error.message).toEqual( + "You cannot capture more than the authorized amount substracted by what is already captured." + ) + }) + + it("should fail to capture already captured payment", async () => { + await service.capturePayment({ + amount: 100, + payment_id: "pay-id-1", + }) + + const error = await service + .capturePayment({ + amount: 100, + payment_id: "pay-id-1", + }) + .catch((e) => e) + + expect(error.message).toEqual( + "You cannot capture more than the authorized amount substracted by what is already captured." + ) + }) + + it("should fail to capture a canceled payment", async () => { + await service.cancelPayment("pay-id-1") + + const error = await service + .capturePayment({ + amount: 100, + payment_id: "pay-id-1", + }) + .catch((e) => e) + + expect(error.message).toEqual( + "The payment: pay-id-1 has been canceled." + ) + }) }) describe("refund", () => { diff --git a/packages/payment/package.json b/packages/payment/package.json index bf972fc088..d676aeeb57 100644 --- a/packages/payment/package.json +++ b/packages/payment/package.json @@ -56,6 +56,7 @@ "@mikro-orm/migrations": "5.9.7", "@mikro-orm/postgresql": "5.9.7", "awilix": "^8.0.0", + "bignumber.js": "^9.1.2", "dotenv": "^16.1.4", "knex": "2.4.2" } diff --git a/packages/payment/src/migrations/Migration20240225134525.ts b/packages/payment/src/migrations/Migration20240225134525.ts index 2601c03cbe..a8195f7491 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( @@ -14,10 +14,11 @@ export class Migration20240225134525 extends Migration { ["type", "created_by"], "DROP NOT NULL" )} - + ALTER TABLE IF EXISTS "payment_collection" ADD COLUMN IF NOT EXISTS "completed_at" TIMESTAMPTZ NULL; ALTER TABLE IF EXISTS "payment_collection" ADD COLUMN IF NOT EXISTS "raw_amount" JSONB NOT NULL; ALTER TABLE IF EXISTS "payment_collection" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL; + ALTER TABLE "payment_collection" DROP CONSTRAINT "FK_payment_collection_region_id"; ALTER TABLE IF EXISTS "payment_provider" ADD COLUMN IF NOT EXISTS "is_enabled" BOOLEAN NOT NULL DEFAULT TRUE; @@ -34,6 +35,7 @@ export class Migration20240225134525 extends Migration { ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "raw_amount" JSONB NOT NULL; ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL; ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "payment_session_id" TEXT NOT NULL; + ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "customer_id" TEXT NULL; 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; @@ -101,6 +103,7 @@ export class Migration20240225134525 extends Migration { CREATE INDEX IF NOT EXISTS "IDX_capture_deleted_at" ON "payment" ("deleted_at") WHERE "deleted_at" IS NOT NULL; CREATE INDEX IF NOT EXISTS "IDX_payment_session_payment_collection_id" ON "payment_session" ("payment_collection_id") WHERE "deleted_at" IS NULL; + `) } else { this.addSql(` @@ -170,7 +173,6 @@ export class Migration20240225134525 extends Migration { "provider_id" TEXT NOT NULL, "cart_id" TEXT NULL, "order_id" TEXT NULL, - "order_edit_id" TEXT NULL, "customer_id" TEXT NULL, "data" JSONB NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), diff --git a/packages/payment/src/models/payment.ts b/packages/payment/src/models/payment.ts index 5ad1cd429c..0800b6a903 100644 --- a/packages/payment/src/models/payment.ts +++ b/packages/payment/src/models/payment.ts @@ -52,9 +52,6 @@ export default class Payment { @Property({ columnType: "text", nullable: true }) order_id: string | null = null - @Property({ columnType: "text", nullable: true }) - order_edit_id: string | null = null - @Property({ columnType: "text", nullable: true }) customer_id: string | null = null diff --git a/packages/payment/src/services/payment-module.ts b/packages/payment/src/services/payment-module.ts index f4be90da14..d6019934ca 100644 --- a/packages/payment/src/services/payment-module.ts +++ b/packages/payment/src/services/payment-module.ts @@ -38,6 +38,7 @@ import { PaymentSession, Refund, } from "@models" +import BigNumber from "bignumber.js" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" import PaymentProviderService from "./payment-provider" @@ -51,7 +52,13 @@ type InjectedDependencies = { paymentProviderService: PaymentProviderService } -const generateMethodForModels = [PaymentCollection, Payment, PaymentSession] +const generateMethodForModels = [ + PaymentCollection, + Payment, + PaymentSession, + Capture, + Refund, +] export default class PaymentModuleService< TPaymentCollection extends PaymentCollection = PaymentCollection, @@ -409,10 +416,25 @@ 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", + "canceled_at", + ], + relations: ["captures.raw_amount"], + }, sharedContext ) + // If no custom amount is passed, we assume the full amount needs to be captured + if (!data.amount) { + data.amount = payment.amount as number + } + if (payment.canceled_at) { throw new MedusaError( MedusaError.Types.INVALID_DATA, @@ -420,22 +442,29 @@ export default class PaymentModuleService< ) } - // this method needs to be idempotent if (payment.captured_at) { - return this.retrievePayment( + return await this.retrievePayment( data.payment_id, { relations: ["captures"] }, sharedContext ) } - // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged - // if (payment.captured_amount + input.amount > payment.amount) { - // throw new MedusaError( - // MedusaError.Types.INVALID_DATA, - // `Total captured amount for payment: ${payment.id} exceeds authorized amount.` - // ) - // } + const capturedAmount = payment.captures.reduce((acc, next) => { + const bn = new BigNumber(next.raw_amount.value) + return acc.plus(bn) + }, BigNumber(0)) + + const authorizedAmount = BigNumber(payment.raw_amount.value) + const newCaptureAmount = BigNumber(data.amount) + const remainingToCapture = authorizedAmount.minus(capturedAmount) + + if (newCaptureAmount.gt(remainingToCapture)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `You cannot capture more than the authorized amount substracted by what is already captured.` + ) + } const paymentData = await this.paymentProviderService_.capturePayment({ data: payment.data!, @@ -456,13 +485,13 @@ export default class PaymentModuleService< sharedContext ) - // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged - // if (payment.captured_amount + data.amount === payment.amount) { - // await this.paymentService_.update( - // { id: payment.id, captured_at: new Date() }, - // sharedContext - // ) - // } + // When the entire authorized amount has been captured, we mark it fully capture by setting the captured_at field + if (capturedAmount.plus(newCaptureAmount).eq(authorizedAmount)) { + await this.paymentService_.update( + { id: payment.id, captured_at: new Date() }, + sharedContext + ) + } return await this.retrievePayment( payment.id, @@ -495,7 +524,7 @@ export default class PaymentModuleService< data: payment.data!, provider_id: payment.provider_id, }, - data.amount + data.amount as number ) await this.refundService_.create( diff --git a/packages/types/src/payment/common.ts b/packages/types/src/payment/common.ts index cbfea66265..4c2b8b0957 100644 --- a/packages/types/src/payment/common.ts +++ b/packages/types/src/payment/common.ts @@ -182,6 +182,26 @@ export interface FilterablePaymentSessionProps deleted_at?: OperatorMap } +export interface FilterableCaptureProps extends BaseFilterable { + id?: string | string[] + currency_code?: string | string[] + amount?: number | OperatorMap + payment_id?: string | string[] + created_at?: OperatorMap + updated_at?: OperatorMap + deleted_at?: OperatorMap +} + +export interface FilterableRefundProps extends BaseFilterable { + id?: string | string[] + currency_code?: string | string[] + amount?: number | OperatorMap + payment_id?: string | string[] + created_at?: OperatorMap + updated_at?: OperatorMap + deleted_at?: OperatorMap +} + /* ********** PAYMENT ********** */ export interface PaymentDTO { /** diff --git a/packages/types/src/payment/mutations.ts b/packages/types/src/payment/mutations.ts index 621b77e38b..6d4b109ca3 100644 --- a/packages/types/src/payment/mutations.ts +++ b/packages/types/src/payment/mutations.ts @@ -144,7 +144,7 @@ export interface CreateCaptureDTO { /** * The amount of the capture. */ - amount: number + amount?: number /** * The associated payment's ID. @@ -164,7 +164,7 @@ export interface CreateRefundDTO { /** * The amount of the refund. */ - amount: number + amount?: number /** * The associated payment's ID. diff --git a/packages/types/src/payment/service.ts b/packages/types/src/payment/service.ts index 2bef19b5dd..0a73402d0f 100644 --- a/packages/types/src/payment/service.ts +++ b/packages/types/src/payment/service.ts @@ -2,14 +2,18 @@ import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" import { Context } from "../shared-context" import { + CaptureDTO, + FilterableCaptureProps, FilterablePaymentCollectionProps, FilterablePaymentProps, FilterablePaymentProviderProps, FilterablePaymentSessionProps, + FilterableRefundProps, PaymentCollectionDTO, PaymentDTO, PaymentProviderDTO, PaymentSessionDTO, + RefundDTO, } from "./common" import { CreateCaptureDTO, @@ -402,6 +406,18 @@ export interface IPaymentModuleService extends IModuleService { sharedContext?: Context ): Promise + listCaptures( + filters?: FilterableCaptureProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listRefunds( + filters?: FilterableRefundProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + /* ********** HOOKS ********** */ processEvent(data: ProviderWebhookPayload): Promise diff --git a/yarn.lock b/yarn.lock index 0ae66844e2..9fdbf6aa18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8664,6 +8664,7 @@ __metadata: "@mikro-orm/migrations": 5.9.7 "@mikro-orm/postgresql": 5.9.7 awilix: ^8.0.0 + bignumber.js: ^9.1.2 cross-env: ^5.2.1 dotenv: ^16.1.4 jest: ^29.6.3