diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index 55ececc285..7cb3a3f4e7 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -1910,6 +1910,7 @@ medusaIntegrationTestRunner({ expect.objectContaining({ currency_code: "usd", amount: 106, + status: "authorized", }), ], }) diff --git a/packages/core/types/src/common/common.ts b/packages/core/types/src/common/common.ts index 8d08135999..db7852d985 100644 --- a/packages/core/types/src/common/common.ts +++ b/packages/core/types/src/common/common.ts @@ -110,6 +110,11 @@ export interface FindConfig { * Enable ORM specific defined filters */ filters?: Record + + /** + * Enable ORM specific defined options + */ + options?: Record } /** diff --git a/packages/core/utils/src/modules-sdk/build-query.ts b/packages/core/utils/src/modules-sdk/build-query.ts index e62eb24f57..05be78e9a8 100644 --- a/packages/core/utils/src/modules-sdk/build-query.ts +++ b/packages/core/utils/src/modules-sdk/build-query.ts @@ -66,6 +66,10 @@ export function buildQuery( } } + if (config.options) { + Object.assign(findOptions, config.options) + } + return { where, options: findOptions } } diff --git a/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts b/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts index 9376a483b1..58f1f54a59 100644 --- a/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts +++ b/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts @@ -1,6 +1,6 @@ import { Modules } from "@medusajs/modules-sdk" import { IPaymentModuleService } from "@medusajs/types" - +import { promiseAll } from "@medusajs/utils" import { moduleIntegrationTestRunner, SuiteOptions, @@ -66,9 +66,9 @@ moduleIntegrationTestRunner({ id: expect.any(String), currency_code: "usd", amount: 200, - // TODO - // authorized_amount: 200, - // status: "authorized", + authorized_amount: 200, + captured_amount: 200, + status: "authorized", region_id: "reg_123", deleted_at: null, completed_at: expect.any(Date), @@ -635,8 +635,6 @@ moduleIntegrationTestRunner({ amount: 100, }), ], - // captured_amount: 100, - // refunded_amount: 100, }) ) }) @@ -685,6 +683,208 @@ moduleIntegrationTestRunner({ // ) // }) }) + + describe("concurrency", () => { + it("should authorize, capture and refund multiple payment sessions", async () => { + const collection = await service.createPaymentCollections({ + amount: 500, + region_id: "test-region", + currency_code: "usd", + }) + + const session1 = await service.createPaymentSession(collection.id, { + provider_id: "pp_system_default", + amount: 120, + currency_code: "usd", + data: {}, + }) + + const session2 = await service.createPaymentSession(collection.id, { + provider_id: "pp_system_default", + amount: 180, + currency_code: "usd", + data: {}, + }) + + const session3 = await service.createPaymentSession(collection.id, { + provider_id: "pp_system_default", + amount: 200, + currency_code: "usd", + data: {}, + }) + + const session4 = await service.createPaymentSession(collection.id, { + provider_id: "pp_system_default", + amount: 500, + currency_code: "eur", + data: {}, + }) + + // authorize + const [payment1, payment2, payment3, payment4] = await promiseAll([ + service.authorizePaymentSession(session1.id, {}), + service.authorizePaymentSession(session2.id, {}), + service.authorizePaymentSession(session3.id, {}), + service.authorizePaymentSession(session4.id, {}), + ]) + + // capture + await promiseAll([ + service.capturePayment({ + amount: 60, + payment_id: payment1.id, + }), + service.capturePayment({ + amount: 60, + payment_id: payment1.id, + }), + service.capturePayment({ + amount: 180, + payment_id: payment2.id, + }), + service.capturePayment({ + amount: 100, + payment_id: payment3.id, + }), + service.capturePayment({ + amount: 40, + payment_id: payment3.id, + }), + service.capturePayment({ + amount: 60, + payment_id: payment3.id, + }), + service.capturePayment({ + amount: 200, + payment_id: payment4.id, + }), + service.capturePayment({ + amount: 200, + payment_id: payment4.id, + }), + service.capturePayment({ + amount: 100, + payment_id: payment4.id, + }), + ]) + + // refund + await promiseAll([ + service.refundPayment({ + amount: 70, + payment_id: payment1.id, + }), + service.refundPayment({ + amount: 50, + payment_id: payment1.id, + }), + service.refundPayment({ + amount: 180, + payment_id: payment2.id, + }), + service.refundPayment({ + amount: 100, + payment_id: payment3.id, + }), + service.refundPayment({ + amount: 40, + payment_id: payment3.id, + }), + service.refundPayment({ + amount: 60, + payment_id: payment3.id, + }), + service.refundPayment({ + amount: 400, + payment_id: payment4.id, + }), + service.refundPayment({ + amount: 99, + payment_id: payment4.id, + }), + ]) + + expect(payment1).toEqual( + expect.objectContaining({ + amount: 120, + currency_code: "usd", + provider_id: "pp_system_default", + payment_session: expect.objectContaining({ + currency_code: "usd", + amount: 120, + raw_amount: { value: "120", precision: 20 }, + provider_id: "pp_system_default", + status: "authorized", + authorized_at: expect.any(Date), + }), + }) + ) + + expect(payment2).toEqual( + expect.objectContaining({ + amount: 180, + currency_code: "usd", + provider_id: "pp_system_default", + payment_session: expect.objectContaining({ + currency_code: "usd", + amount: 180, + raw_amount: { value: "180", precision: 20 }, + provider_id: "pp_system_default", + status: "authorized", + authorized_at: expect.any(Date), + }), + }) + ) + + expect(payment3).toEqual( + expect.objectContaining({ + amount: 200, + currency_code: "usd", + provider_id: "pp_system_default", + payment_session: expect.objectContaining({ + currency_code: "usd", + amount: 200, + raw_amount: { value: "200", precision: 20 }, + provider_id: "pp_system_default", + status: "authorized", + authorized_at: expect.any(Date), + }), + }) + ) + + expect(payment4).toEqual( + expect.objectContaining({ + amount: 500, + currency_code: "eur", + provider_id: "pp_system_default", + payment_session: expect.objectContaining({ + currency_code: "eur", + amount: 500, + raw_amount: { value: "500", precision: 20 }, + provider_id: "pp_system_default", + status: "authorized", + authorized_at: expect.any(Date), + }), + }) + ) + + const finalCollection = ( + await service.listPaymentCollections({ + id: collection.id, + }) + )[0] + + expect(finalCollection).toEqual( + expect.objectContaining({ + status: "authorized", + amount: 500, + authorized_amount: 1000, + captured_amount: 1000, + refunded_amount: 999, + }) + ) + }) + }) }) }) }, diff --git a/packages/modules/payment/src/migrations/Migration20240225134525.ts b/packages/modules/payment/src/migrations/Migration20240225134525.ts index 6a0d3e4537..cba7dba7d2 100644 --- a/packages/modules/payment/src/migrations/Migration20240225134525.ts +++ b/packages/modules/payment/src/migrations/Migration20240225134525.ts @@ -18,6 +18,16 @@ export class Migration20240225134525 extends Migration { 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 IF EXISTS "payment_collection" ADD COLUMN IF NOT EXISTS "authorized_amount" NUMERIC NULL; + ALTER TABLE IF EXISTS "payment_collection" ADD COLUMN IF NOT EXISTS "raw_authorized_amount" JSONB NULL; + + ALTER TABLE IF EXISTS "payment_collection" ADD COLUMN IF NOT EXISTS "captured_amount" NUMERIC NULL; + ALTER TABLE IF EXISTS "payment_collection" ADD COLUMN IF NOT EXISTS "raw_captured_amount" JSONB NULL; + + ALTER TABLE IF EXISTS "payment_collection" ADD COLUMN IF NOT EXISTS "refunded_amount" NUMERIC NULL; + ALTER TABLE IF EXISTS "payment_collection" ADD COLUMN IF NOT EXISTS "raw_refunded_amount" JSONB 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; @@ -119,6 +129,12 @@ export class Migration20240225134525 extends Migration { "currency_code" TEXT NOT NULL, "amount" NUMERIC NOT NULL, "raw_amount" JSONB NOT NULL, + "authorized_amount" NUMERIC NULL, + "raw_authorized_amount" JSONB NULL, + "captured_amount" NUMERIC NULL, + "raw_captured_amount" JSONB NULL, + "refunded_amount" NUMERIC NULL, + "raw_refunded_amount" JSONB NULL, "region_id" TEXT NOT NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), diff --git a/packages/modules/payment/src/models/payment-collection.ts b/packages/modules/payment/src/models/payment-collection.ts index 295986e452..b6bea50946 100644 --- a/packages/modules/payment/src/models/payment-collection.ts +++ b/packages/modules/payment/src/models/payment-collection.ts @@ -43,6 +43,24 @@ export default class PaymentCollection { @Property({ columnType: "jsonb" }) raw_amount: BigNumberRawValue + @MikroOrmBigNumberProperty({ nullable: true }) + authorized_amount: BigNumber | number | null = null + + @Property({ columnType: "jsonb", nullable: true }) + raw_authorized_amount: BigNumberRawValue | null = null + + @MikroOrmBigNumberProperty({ nullable: true }) + captured_amount: BigNumber | number | null = null + + @Property({ columnType: "jsonb", nullable: true }) + raw_captured_amount: BigNumberRawValue | null = null + + @MikroOrmBigNumberProperty({ nullable: true }) + refunded_amount: BigNumber | number | null = null + + @Property({ columnType: "jsonb", nullable: true }) + raw_refunded_amount: BigNumberRawValue | null = null + @Property({ columnType: "text", index: "IDX_payment_collection_region_id" }) region_id: string diff --git a/packages/modules/payment/src/models/payment.ts b/packages/modules/payment/src/models/payment.ts index 5ee84bf01b..d8d7006739 100644 --- a/packages/modules/payment/src/models/payment.ts +++ b/packages/modules/payment/src/models/payment.ts @@ -131,13 +131,6 @@ export default class Payment { }) payment_session: PaymentSession - /** COMPUTED PROPERTIES START **/ - - captured_amount: number // sum of the associated captures - refunded_amount: number // sum of the associated refunds - - /** COMPUTED PROPERTIES END **/ - @BeforeCreate() onCreate() { this.id = generateEntityId(this.id, "pay") diff --git a/packages/modules/payment/src/services/payment-module.ts b/packages/modules/payment/src/services/payment-module.ts index ce9a0a8067..49f65e0310 100644 --- a/packages/modules/payment/src/services/payment-module.ts +++ b/packages/modules/payment/src/services/payment-module.ts @@ -36,8 +36,10 @@ import { MedusaError, ModulesSdkUtils, PaymentActions, + PaymentCollectionStatus, promiseAll, } from "@medusajs/utils" +import { IsolationLevel } from "@mikro-orm/core" import { Capture, Payment, @@ -404,6 +406,9 @@ export default class PaymentModuleService< context: Record, @MedusaContext() sharedContext?: Context ): Promise { + sharedContext ??= {} + sharedContext.isolationLevel = IsolationLevel.SERIALIZABLE + const session = await this.paymentSessionService_.retrieve( id, { @@ -456,7 +461,10 @@ export default class PaymentModuleService< ) } - // TODO: update status on payment collection if authorized_amount === amount - depends on the BigNumber PR + await this.maybeUpdatePaymentCollection_( + session.payment_collection_id, + sharedContext + ) const payment = await this.paymentService_.create( { @@ -491,11 +499,30 @@ export default class PaymentModuleService< }) } - @InjectTransactionManager("baseRepository_") + @InjectManager("baseRepository_") async capturePayment( data: CreateCaptureDTO, @MedusaContext() sharedContext: Context = {} ): Promise { + const payment = (await this.capturePayment_(data, sharedContext)) as Payment + + await this.maybeUpdatePaymentCollection_( + payment.payment_collection_id, + sharedContext + ) + + return await this.retrievePayment( + payment.id, + { relations: ["captures"] }, + sharedContext + ) + } + + @InjectTransactionManager("baseRepository_") + private async capturePayment_( + data: CreateCaptureDTO, + @MedusaContext() sharedContext: Context = {} + ) { const payment = await this.paymentService_.retrieve( data.payment_id, { @@ -503,6 +530,7 @@ export default class PaymentModuleService< "id", "data", "provider_id", + "payment_collection_id", "amount", "raw_amount", "canceled_at", @@ -561,38 +589,58 @@ export default class PaymentModuleService< sharedContext ) - await this.paymentService_.update( - { id: payment.id, data: paymentData }, - sharedContext - ) - // When the entire authorized amount has been captured, we mark it fully capture by setting the captured_at field + let capturedAt: Date | null = null const totalCaptured = MathBN.convert( MathBN.add(capturedAmount, newCaptureAmount) ) if (MathBN.gte(totalCaptured, authorizedAmount)) { - await this.paymentService_.update( - { id: payment.id, captured_at: new Date() }, - sharedContext - ) + capturedAt = new Date() } + await this.paymentService_.update( + { id: payment.id, data: paymentData, captured_at: capturedAt }, + sharedContext + ) + + return payment + } + + @InjectManager("baseRepository_") + async refundPayment( + data: CreateRefundDTO, + @MedusaContext() sharedContext?: Context + ): Promise { + const payment = await this.refundPayment_(data, sharedContext) + + await this.maybeUpdatePaymentCollection_( + payment.payment_collection_id, + sharedContext + ) + return await this.retrievePayment( payment.id, - { relations: ["captures"] }, + { relations: ["refunds"] }, sharedContext ) } @InjectTransactionManager("baseRepository_") - async refundPayment( + private async refundPayment_( data: CreateRefundDTO, @MedusaContext() sharedContext?: Context - ): Promise { + ) { const payment = await this.paymentService_.retrieve( data.payment_id, { - select: ["id", "data", "provider_id", "amount", "raw_amount"], + select: [ + "id", + "data", + "provider_id", + "payment_collection_id", + "amount", + "raw_amount", + ], relations: ["captures.raw_amount"], }, sharedContext @@ -637,11 +685,7 @@ export default class PaymentModuleService< sharedContext ) - return await this.retrievePayment( - payment.id, - { relations: ["refunds"] }, - sharedContext - ) + return payment } @InjectTransactionManager("baseRepository_") @@ -756,4 +800,74 @@ export default class PaymentModuleService< count, ] } + + @InjectTransactionManager("baseRepository_") + private async maybeUpdatePaymentCollection_( + paymentCollectionId: string, + sharedContext?: Context + ) { + const paymentCollection = await this.paymentCollectionService_.retrieve( + paymentCollectionId, + { + select: ["amount", "raw_amount", "status"], + relations: [ + "payment_sessions.amount", + "payment_sessions.raw_amount", + "payments.captures.amount", + "payments.captures.raw_amount", + "payments.refunds.amount", + "payments.refunds.raw_amount", + ], + }, + sharedContext + ) + + const paymentSessions = paymentCollection.payment_sessions + const captures = paymentCollection.payments + .map((pay) => [...pay.captures]) + .flat() + const refunds = paymentCollection.payments + .map((pay) => [...pay.refunds]) + .flat() + + let authorizedAmount = MathBN.convert(0) + let capturedAmount = MathBN.convert(0) + let refundedAmount = MathBN.convert(0) + + for (const ps of paymentSessions) { + if (ps.status === PaymentSessionStatus.AUTHORIZED) { + authorizedAmount = MathBN.add(authorizedAmount, ps.amount) + } + } + + for (const capture of captures) { + capturedAmount = MathBN.add(capturedAmount, capture.amount) + } + + for (const refund of refunds) { + refundedAmount = MathBN.add(refundedAmount, refund.amount) + } + + let status = + paymentSessions.length === 0 + ? PaymentCollectionStatus.NOT_PAID + : PaymentCollectionStatus.AWAITING + + if (MathBN.gt(authorizedAmount, 0)) { + status = MathBN.gte(authorizedAmount, paymentCollection.amount) + ? PaymentCollectionStatus.AUTHORIZED + : PaymentCollectionStatus.PARTIALLY_AUTHORIZED + } + + await this.paymentCollectionService_.update( + { + id: paymentCollectionId, + status, + authorized_amount: authorizedAmount, + captured_amount: capturedAmount, + refunded_amount: refundedAmount, + }, + sharedContext + ) + } }