feat(payment): update payment collection status (#7335)

This commit is contained in:
Carlos R. L. Rodrigues
2024-05-15 12:11:40 -03:00
committed by GitHub
parent 774696845c
commit 7c4f4d7388
8 changed files with 384 additions and 33 deletions

View File

@@ -1910,6 +1910,7 @@ medusaIntegrationTestRunner({
expect.objectContaining({
currency_code: "usd",
amount: 106,
status: "authorized",
}),
],
})

View File

@@ -110,6 +110,11 @@ export interface FindConfig<Entity> {
* Enable ORM specific defined filters
*/
filters?: Record<string, any>
/**
* Enable ORM specific defined options
*/
options?: Record<string, any>
}
/**

View File

@@ -66,6 +66,10 @@ export function buildQuery<T = any, TDto = any>(
}
}
if (config.options) {
Object.assign(findOptions, config.options)
}
return { where, options: findOptions }
}

View File

@@ -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,
})
)
})
})
})
})
},

View File

@@ -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(),

View File

@@ -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

View File

@@ -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")

View File

@@ -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<string, unknown>,
@MedusaContext() sharedContext?: Context
): Promise<PaymentDTO> {
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<PaymentDTO> {
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<PaymentDTO> {
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<PaymentDTO> {
) {
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
)
}
}