feat: Capture payment (#6601)

* feat: Capture payment

* add amount to workflow input
This commit is contained in:
Oli Juhl authored and GitHub committed 2024-03-07 11:02:26 +01:00
1 parent 12b035cb18
commit 3d0da980cf
23 files changed
+584 -86

No files matched your search

@@ -1,5 +1,10 @@
import { ModuleRegistrationName, Modules } from "@medusajs/modules-sdk" import { capturePaymentWorkflow } from "@medusajs/core-flows"
import { IRegionModuleService } from "@medusajs/types" import {
LinkModuleUtils,
ModuleRegistrationName,
Modules,
} from "@medusajs/modules-sdk"
import { IPaymentModuleService, IRegionModuleService } from "@medusajs/types"
import { medusaIntegrationTestRunner } from "medusa-test-utils/dist" import { medusaIntegrationTestRunner } from "medusa-test-utils/dist"
jest.setTimeout(50000) jest.setTimeout(50000)
@@ -12,14 +17,17 @@ medusaIntegrationTestRunner({
describe("Payments", () => { describe("Payments", () => {
let appContainer let appContainer
let regionService: IRegionModuleService let regionService: IRegionModuleService
let paymentService: IPaymentModuleService
let remoteLink let remoteLink
beforeAll(async () => { beforeAll(async () => {
appContainer = getContainer() appContainer = getContainer()
regionService = appContainer.resolve(ModuleRegistrationName.REGION) 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 () => { it("should list payment providers", async () => {
const region = await regionService.create({ const region = await regionService.create({
name: "Test Region", 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,
})
)
})
}) })
}, },
}) })
+5 -4
View File
@@ -1,13 +1,14 @@
export * from "./api-key"
export * from "./customer" export * from "./customer"
export * from "./customer-group" export * from "./customer-group"
export * from "./definition" export * from "./definition"
export * from "./definitions" export * from "./definitions"
export * as Handlers from "./handlers" export * as Handlers from "./handlers"
export * from "./invite" export * from "./invite"
export * from "./payment"
export * from "./product"
export * from "./promotion" export * from "./promotion"
export * from "./region" export * from "./region"
export * from "./user"
export * from "./tax"
export * from "./api-key"
export * from "./store" export * from "./store"
export * from "./product" export * from "./tax"
export * from "./user"
+2
View File
@@ -0,0 +1,2 @@
export * from "./steps"
export * from "./workflows"
@@ -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<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
const payment = await paymentModule.capturePayment(input)
return new StepResponse(payment)
}
)
@@ -0,0 +1 @@
export * from "./capture-payment"
@@ -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<PaymentDTO> => {
const payment = capturePaymentStep(input)
return payment
}
)
@@ -0,0 +1 @@
export * from "./capture-payment"
@@ -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 })
}
@@ -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 })
}
@@ -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: [],
// },
]
@@ -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,
}
@@ -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,
})
}
@@ -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
}
@@ -5,6 +5,7 @@ import { adminCurrencyRoutesMiddlewares } from "./admin/currencies/middlewares"
import { adminCustomerGroupRoutesMiddlewares } from "./admin/customer-groups/middlewares" import { adminCustomerGroupRoutesMiddlewares } from "./admin/customer-groups/middlewares"
import { adminCustomerRoutesMiddlewares } from "./admin/customers/middlewares" import { adminCustomerRoutesMiddlewares } from "./admin/customers/middlewares"
import { adminInviteRoutesMiddlewares } from "./admin/invites/middlewares" import { adminInviteRoutesMiddlewares } from "./admin/invites/middlewares"
import { adminPaymentRoutesMiddlewares } from "./admin/payments/middlewares"
import { adminPriceListsRoutesMiddlewares } from "./admin/price-lists/middlewares" import { adminPriceListsRoutesMiddlewares } from "./admin/price-lists/middlewares"
import { adminProductRoutesMiddlewares } from "./admin/products/middlewares" import { adminProductRoutesMiddlewares } from "./admin/products/middlewares"
import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares" import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares"
@@ -44,6 +45,7 @@ export const config: MiddlewaresConfig = {
...adminCurrencyRoutesMiddlewares, ...adminCurrencyRoutesMiddlewares,
...storeCurrencyRoutesMiddlewares, ...storeCurrencyRoutesMiddlewares,
...adminProductRoutesMiddlewares, ...adminProductRoutesMiddlewares,
...adminPaymentRoutesMiddlewares,
...adminPriceListsRoutesMiddlewares, ...adminPriceListsRoutesMiddlewares,
], ],
} }
@@ -444,7 +444,6 @@ moduleIntegrationTestRunner({
data: {}, data: {},
cart_id: null, cart_id: null,
order_id: null, order_id: null,
order_edit_id: null,
customer_id: null, customer_id: null,
deleted_at: null, deleted_at: null,
captured_at: null, captured_at: null,
@@ -498,7 +497,7 @@ moduleIntegrationTestRunner({
}) })
describe("capture", () => { 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({ const capturedPayment = await service.capturePayment({
amount: 100, amount: 100,
payment_id: "pay-id-1", payment_id: "pay-id-1",
@@ -515,61 +514,104 @@ moduleIntegrationTestRunner({
amount: 100, amount: 100,
}), }),
], ],
captured_at: expect.any(Date),
// TODO: uncomment when totals calculations are implemented
// captured_amount: 100,
// 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 capturedPayment = await service.capturePayment({
// const error = await service amount: 50,
// .capturePayment({ payment_id: "pay-id-1",
// amount: 200, })
// payment_id: "pay-id-1",
// }) expect(capturedPayment).toEqual(
// .catch((e) => e) expect.objectContaining({
// id: "pay-id-1",
// expect(error.message).toEqual( amount: 100,
// "Total captured amount for payment: pay-id-1 exceeds authorised amount."
// ) captures: [
// }) expect.objectContaining({
// created_by: null,
// it("should fail to capture already captured payment", async () => { amount: 50,
// await service.capturePayment({ }),
// amount: 100, expect.objectContaining({
// payment_id: "pay-id-1", created_by: null,
// }) amount: 50,
// }),
// const error = await service ],
// .capturePayment({ })
// amount: 100, )
// payment_id: "pay-id-1", })
// })
// .catch((e) => e) it("should fail to capture amount greater than authorized", async () => {
// const error = await service
// expect(error.message).toEqual( .capturePayment({
// "The payment: pay-id-1 is already fully captured." amount: 200,
// ) payment_id: "pay-id-1",
// }) })
// .catch((e) => e)
// it("should fail to capture a canceled payment", async () => {
// await service.cancelPayment("pay-id-1") expect(error.message).toEqual(
// "You cannot capture more than the authorized amount substracted by what is already captured."
// const error = await service )
// .capturePayment({ })
// amount: 100,
// payment_id: "pay-id-1", it("should fail to capture amount greater than what is already captured", async () => {
// }) await service.capturePayment({
// .catch((e) => e) amount: 99,
// payment_id: "pay-id-1",
// expect(error.message).toEqual( })
// "The payment: pay-id-1 has been canceled."
// ) 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", () => { describe("refund", () => {
+1
View File
@@ -56,6 +56,7 @@
"@mikro-orm/migrations": "5.9.7", "@mikro-orm/migrations": "5.9.7",
"@mikro-orm/postgresql": "5.9.7", "@mikro-orm/postgresql": "5.9.7",
"awilix": "^8.0.0", "awilix": "^8.0.0",
"bignumber.js": "^9.1.2",
"dotenv": "^16.1.4", "dotenv": "^16.1.4",
"knex": "2.4.2" "knex": "2.4.2"
} }
@@ -6,7 +6,7 @@ export class Migration20240225134525 extends Migration {
const paymentCollectionExists = await this.execute( const paymentCollectionExists = await this.execute(
`SELECT * FROM information_schema.tables where table_name = 'payment_collection' and table_schema = 'public';` `SELECT * FROM information_schema.tables where table_name = 'payment_collection' and table_schema = 'public';`
) )
if (paymentCollectionExists.length) { if (paymentCollectionExists.length) {
this.addSql(` this.addSql(`
${generatePostgresAlterColummnIfExistStatement( ${generatePostgresAlterColummnIfExistStatement(
@@ -14,10 +14,11 @@ export class Migration20240225134525 extends Migration {
["type", "created_by"], ["type", "created_by"],
"DROP NOT NULL" "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 "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 "raw_amount" JSONB NOT NULL;
ALTER TABLE IF EXISTS "payment_collection" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ 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; 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 "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 "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 "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 "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 "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_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; CREATE INDEX IF NOT EXISTS "IDX_payment_session_payment_collection_id" ON "payment_session" ("payment_collection_id") WHERE "deleted_at" IS NULL;
`) `)
} else { } else {
this.addSql(` this.addSql(`
@@ -170,7 +173,6 @@ export class Migration20240225134525 extends Migration {
"provider_id" TEXT NOT NULL, "provider_id" TEXT NOT NULL,
"cart_id" TEXT NULL, "cart_id" TEXT NULL,
"order_id" TEXT NULL, "order_id" TEXT NULL,
"order_edit_id" TEXT NULL,
"customer_id" TEXT NULL, "customer_id" TEXT NULL,
"data" JSONB NULL, "data" JSONB NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-3
View File
@@ -52,9 +52,6 @@ export default class Payment {
@Property({ columnType: "text", nullable: true }) @Property({ columnType: "text", nullable: true })
order_id: string | null = null order_id: string | null = null
@Property({ columnType: "text", nullable: true })
order_edit_id: string | null = null
@Property({ columnType: "text", nullable: true }) @Property({ columnType: "text", nullable: true })
customer_id: string | null = null customer_id: string | null = null
+48 -19
View File
@@ -38,6 +38,7 @@ import {
PaymentSession, PaymentSession,
Refund, Refund,
} from "@models" } from "@models"
import BigNumber from "bignumber.js"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import PaymentProviderService from "./payment-provider" import PaymentProviderService from "./payment-provider"
@@ -51,7 +52,13 @@ type InjectedDependencies = {
paymentProviderService: PaymentProviderService paymentProviderService: PaymentProviderService
} }
const generateMethodForModels = [PaymentCollection, Payment, PaymentSession] const generateMethodForModels = [
PaymentCollection,
Payment,
PaymentSession,
Capture,
Refund,
]
export default class PaymentModuleService< export default class PaymentModuleService<
TPaymentCollection extends PaymentCollection = PaymentCollection, TPaymentCollection extends PaymentCollection = PaymentCollection,
@@ -409,10 +416,25 @@ export default class PaymentModuleService<
): Promise<PaymentDTO> { ): Promise<PaymentDTO> {
const payment = await this.paymentService_.retrieve( const payment = await this.paymentService_.retrieve(
data.payment_id, data.payment_id,
{ select: ["id", "data", "provider_id"] }, {
select: [
"id",
"data",
"provider_id",
"amount",
"raw_amount",
"canceled_at",
],
relations: ["captures.raw_amount"],
},
sharedContext 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) { if (payment.canceled_at) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,
@@ -420,22 +442,29 @@ export default class PaymentModuleService<
) )
} }
// this method needs to be idempotent
if (payment.captured_at) { if (payment.captured_at) {
return this.retrievePayment( return await this.retrievePayment(
data.payment_id, data.payment_id,
{ relations: ["captures"] }, { relations: ["captures"] },
sharedContext sharedContext
) )
} }
// TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged const capturedAmount = payment.captures.reduce((acc, next) => {
// if (payment.captured_amount + input.amount > payment.amount) { const bn = new BigNumber(next.raw_amount.value)
// throw new MedusaError( return acc.plus(bn)
// MedusaError.Types.INVALID_DATA, }, BigNumber(0))
// `Total captured amount for payment: ${payment.id} exceeds authorized amount.`
// ) 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({ const paymentData = await this.paymentProviderService_.capturePayment({
data: payment.data!, data: payment.data!,
@@ -456,13 +485,13 @@ export default class PaymentModuleService<
sharedContext sharedContext
) )
// TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged // When the entire authorized amount has been captured, we mark it fully capture by setting the captured_at field
// if (payment.captured_amount + data.amount === payment.amount) { if (capturedAmount.plus(newCaptureAmount).eq(authorizedAmount)) {
// await this.paymentService_.update( await this.paymentService_.update(
// { id: payment.id, captured_at: new Date() }, { id: payment.id, captured_at: new Date() },
// sharedContext sharedContext
// ) )
// } }
return await this.retrievePayment( return await this.retrievePayment(
payment.id, payment.id,
@@ -495,7 +524,7 @@ export default class PaymentModuleService<
data: payment.data!, data: payment.data!,
provider_id: payment.provider_id, provider_id: payment.provider_id,
}, },
data.amount data.amount as number
) )
await this.refundService_.create( await this.refundService_.create(
+20
View File
@@ -182,6 +182,26 @@ export interface FilterablePaymentSessionProps
deleted_at?: OperatorMap<string> deleted_at?: OperatorMap<string>
} }
export interface FilterableCaptureProps extends BaseFilterable<CaptureDTO> {
id?: string | string[]
currency_code?: string | string[]
amount?: number | OperatorMap<number>
payment_id?: string | string[]
created_at?: OperatorMap<string>
updated_at?: OperatorMap<string>
deleted_at?: OperatorMap<string>
}
export interface FilterableRefundProps extends BaseFilterable<RefundDTO> {
id?: string | string[]
currency_code?: string | string[]
amount?: number | OperatorMap<number>
payment_id?: string | string[]
created_at?: OperatorMap<string>
updated_at?: OperatorMap<string>
deleted_at?: OperatorMap<string>
}
/* ********** PAYMENT ********** */ /* ********** PAYMENT ********** */
export interface PaymentDTO { export interface PaymentDTO {
/** /**
+2 -2
View File
@@ -144,7 +144,7 @@ export interface CreateCaptureDTO {
/** /**
* The amount of the capture. * The amount of the capture.
*/ */
amount: number amount?: number
/** /**
* The associated payment's ID. * The associated payment's ID.
@@ -164,7 +164,7 @@ export interface CreateRefundDTO {
/** /**
* The amount of the refund. * The amount of the refund.
*/ */
amount: number amount?: number
/** /**
* The associated payment's ID. * The associated payment's ID.
+16
View File
@@ -2,14 +2,18 @@ import { FindConfig } from "../common"
import { IModuleService } from "../modules-sdk" import { IModuleService } from "../modules-sdk"
import { Context } from "../shared-context" import { Context } from "../shared-context"
import { import {
CaptureDTO,
FilterableCaptureProps,
FilterablePaymentCollectionProps, FilterablePaymentCollectionProps,
FilterablePaymentProps, FilterablePaymentProps,
FilterablePaymentProviderProps, FilterablePaymentProviderProps,
FilterablePaymentSessionProps, FilterablePaymentSessionProps,
FilterableRefundProps,
PaymentCollectionDTO, PaymentCollectionDTO,
PaymentDTO, PaymentDTO,
PaymentProviderDTO, PaymentProviderDTO,
PaymentSessionDTO, PaymentSessionDTO,
RefundDTO,
} from "./common" } from "./common"
import { import {
CreateCaptureDTO, CreateCaptureDTO,
@@ -402,6 +406,18 @@ export interface IPaymentModuleService extends IModuleService {
sharedContext?: Context sharedContext?: Context
): Promise<PaymentProviderDTO[]> ): Promise<PaymentProviderDTO[]>
listCaptures(
filters?: FilterableCaptureProps,
config?: FindConfig<CaptureDTO>,
sharedContext?: Context
): Promise<CaptureDTO[]>
listRefunds(
filters?: FilterableRefundProps,
config?: FindConfig<RefundDTO>,
sharedContext?: Context
): Promise<RefundDTO[]>
/* ********** HOOKS ********** */ /* ********** HOOKS ********** */
processEvent(data: ProviderWebhookPayload): Promise<void> processEvent(data: ProviderWebhookPayload): Promise<void>
+1
View File
@@ -8664,6 +8664,7 @@ __metadata:
"@mikro-orm/migrations": 5.9.7 "@mikro-orm/migrations": 5.9.7
"@mikro-orm/postgresql": 5.9.7 "@mikro-orm/postgresql": 5.9.7
awilix: ^8.0.0 awilix: ^8.0.0
bignumber.js: ^9.1.2
cross-env: ^5.2.1 cross-env: ^5.2.1
dotenv: ^16.1.4 dotenv: ^16.1.4
jest: ^29.6.3 jest: ^29.6.3