fix(payment): round curency precision (#12803)

This commit is contained in:
Carlos R. L. Rodrigues authored and GitHub committed 2025-06-24 18:41:14 +02:00
1 parent 93cf79cb0f
commit ba1e6595b7
4 files changed
+151 -17

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@medusajs/payment": patch
"@medusajs/utils": patch
---
fix(payment): round currency decimal precision
+12 -5
View File
@@ -5,20 +5,27 @@ import { BigNumber } from "./big-number"
type BNInput = BigNumberInput | BigNumber type BNInput = BigNumberInput | BigNumber
export class MathBN { export class MathBN {
static convert(num: BNInput): BigNumberJS { static convert(num: BNInput, decimalPlaces?: number): BigNumberJS {
if (num == null) { if (num == null) {
return new BigNumberJS(0) return new BigNumberJS(0)
} }
let num_ = num
if (num instanceof BigNumber) { if (num instanceof BigNumber) {
return num.bigNumber! num_ = num.bigNumber!
} else if (num instanceof BigNumberJS) { } else if (num instanceof BigNumberJS) {
return num num_ = num
} else if (isDefined((num as BigNumberRawValue)?.value)) { } else if (isDefined((num as BigNumberRawValue)?.value)) {
return new BigNumberJS((num as BigNumberRawValue).value) num_ = new BigNumberJS((num as BigNumberRawValue).value)
} else {
num_ = new BigNumberJS(num as BigNumberJS | number)
} }
return new BigNumberJS(num as BigNumberJS | number) if (decimalPlaces) {
num_ = num_.decimalPlaces(decimalPlaces)
}
return num_
} }
static add(...nums: BNInput[]): BigNumberJS { static add(...nums: BNInput[]): BigNumberJS {
@@ -168,6 +168,79 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
}) })
) )
}) })
it("complete payment flow successfully when rounded numbers are equal", async () => {
let paymentCollection = await service.createPaymentCollections({
currency_code: "usd",
amount: 200.129,
})
const paymentSession = await service.createPaymentSession(
paymentCollection.id,
{
provider_id: "pp_system_default",
amount: 200.129,
currency_code: "usd",
data: {},
context: {
customer: { id: "cus-id-1", email: "new@test.tsst" },
},
}
)
const payment = await service.authorizePaymentSession(
paymentSession.id,
{}
)
await service.capturePayment({
amount: 200.13, // rounded from payment provider
payment_id: payment.id,
})
await service.completePaymentCollections(paymentCollection.id)
paymentCollection = await service.retrievePaymentCollection(
paymentCollection.id,
{ relations: ["payment_sessions", "payments.captures"] }
)
expect(paymentCollection).toEqual(
expect.objectContaining({
id: expect.any(String),
currency_code: "usd",
amount: 200.129,
authorized_amount: 200.129,
captured_amount: 200.13,
status: "completed",
deleted_at: null,
completed_at: expect.any(Date),
payment_sessions: [
expect.objectContaining({
id: expect.any(String),
currency_code: "usd",
amount: 200.129,
provider_id: "pp_system_default",
status: "authorized",
authorized_at: expect.any(Date),
}),
],
payments: [
expect.objectContaining({
id: expect.any(String),
amount: 200.129,
currency_code: "usd",
provider_id: "pp_system_default",
captures: [
expect.objectContaining({
amount: 200.13,
}),
],
}),
],
})
)
})
}) })
describe("PaymentCollection", () => { describe("PaymentCollection", () => {
@@ -1032,7 +1105,7 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
expect(finalCollection).toEqual( expect(finalCollection).toEqual(
expect.objectContaining({ expect.objectContaining({
status: "authorized", status: "completed",
amount: 500, amount: 500,
authorized_amount: 1000, authorized_amount: 1000,
captured_amount: 1000, captured_amount: 1000,
@@ -1,19 +1,22 @@
import { import {
AccountHolderDTO,
BigNumberInput, BigNumberInput,
CaptureDTO, CaptureDTO,
Context, Context,
CreateAccountHolderDTO,
CreateAccountHolderOutput,
CreateCaptureDTO, CreateCaptureDTO,
CreatePaymentCollectionDTO, CreatePaymentCollectionDTO,
CreatePaymentMethodDTO, CreatePaymentMethodDTO,
CreatePaymentSessionDTO, CreatePaymentSessionDTO,
CreateRefundDTO, CreateRefundDTO,
AccountHolderDTO,
DAL, DAL,
FilterablePaymentCollectionProps, FilterablePaymentCollectionProps,
FilterablePaymentMethodProps, FilterablePaymentMethodProps,
FilterablePaymentProviderProps, FilterablePaymentProviderProps,
FindConfig, FindConfig,
InferEntityType, InferEntityType,
InitiatePaymentOutput,
InternalModuleDeclaration, InternalModuleDeclaration,
IPaymentModuleService, IPaymentModuleService,
Logger, Logger,
@@ -28,16 +31,13 @@ import {
ProviderWebhookPayload, ProviderWebhookPayload,
RefundDTO, RefundDTO,
RefundReasonDTO, RefundReasonDTO,
UpdateAccountHolderDTO,
UpdateAccountHolderOutput,
UpdatePaymentCollectionDTO, UpdatePaymentCollectionDTO,
UpdatePaymentDTO, UpdatePaymentDTO,
UpdatePaymentSessionDTO, UpdatePaymentSessionDTO,
CreateAccountHolderDTO,
UpsertPaymentCollectionDTO, UpsertPaymentCollectionDTO,
WebhookActionResult, WebhookActionResult,
CreateAccountHolderOutput,
InitiatePaymentOutput,
UpdateAccountHolderDTO,
UpdateAccountHolderOutput,
} from "@medusajs/framework/types" } from "@medusajs/framework/types"
import { import {
BigNumber, BigNumber,
@@ -152,6 +152,25 @@ export default class PaymentModuleService
return joinerConfig return joinerConfig
} }
protected roundToCurrencyPrecision(
amount: BigNumberInput,
currencyCode: string
): BigNumberInput {
let precision: number | undefined = undefined
try {
const formatted = Intl.NumberFormat(undefined, {
style: "currency",
currency: currencyCode,
}).format(0.1111111)
precision = formatted.split(".")[1].length
} catch {
// Unknown currency, keep the full precision
}
return MathBN.convert(amount, precision)
}
// @ts-expect-error // @ts-expect-error
createPaymentCollections( createPaymentCollections(
data: CreatePaymentCollectionDTO, data: CreatePaymentCollectionDTO,
@@ -627,6 +646,7 @@ export default class PaymentModuleService
"payment_collection_id", "payment_collection_id",
"amount", "amount",
"raw_amount", "raw_amount",
"currency_code",
"captured_at", "captured_at",
"canceled_at", "canceled_at",
], ],
@@ -698,7 +718,12 @@ export default class PaymentModuleService
const newCaptureAmount = new BigNumber(data.amount) const newCaptureAmount = new BigNumber(data.amount)
const remainingToCapture = MathBN.sub(authorizedAmount, capturedAmount) const remainingToCapture = MathBN.sub(authorizedAmount, capturedAmount)
if (MathBN.gt(newCaptureAmount, remainingToCapture)) { if (
MathBN.gt(
this.roundToCurrencyPrecision(newCaptureAmount, payment.currency_code),
this.roundToCurrencyPrecision(remainingToCapture, payment.currency_code)
)
) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,
`You cannot capture more than the authorized amount substracted by what is already captured.` `You cannot capture more than the authorized amount substracted by what is already captured.`
@@ -709,7 +734,10 @@ export default class PaymentModuleService
const totalCaptured = MathBN.convert( const totalCaptured = MathBN.convert(
MathBN.add(capturedAmount, newCaptureAmount) MathBN.add(capturedAmount, newCaptureAmount)
) )
const isFullyCaptured = MathBN.gte(totalCaptured, authorizedAmount) const isFullyCaptured = MathBN.gte(
this.roundToCurrencyPrecision(totalCaptured, payment.currency_code),
this.roundToCurrencyPrecision(authorizedAmount, payment.currency_code)
)
const capture = await this.captureService_.create( const capture = await this.captureService_.create(
{ {
@@ -892,7 +920,7 @@ export default class PaymentModuleService
const paymentCollection = await this.paymentCollectionService_.retrieve( const paymentCollection = await this.paymentCollectionService_.retrieve(
paymentCollectionId, paymentCollectionId,
{ {
select: ["amount", "raw_amount", "status"], select: ["amount", "raw_amount", "status", "currency_code"],
relations: [ relations: [
"payment_sessions.amount", "payment_sessions.amount",
"payment_sessions.raw_amount", "payment_sessions.raw_amount",
@@ -938,12 +966,32 @@ export default class PaymentModuleService
: PaymentCollectionStatus.AWAITING : PaymentCollectionStatus.AWAITING
if (MathBN.gt(authorizedAmount, 0)) { if (MathBN.gt(authorizedAmount, 0)) {
status = MathBN.gte(authorizedAmount, paymentCollection.amount) status = MathBN.gte(
this.roundToCurrencyPrecision(
authorizedAmount,
paymentCollection.currency_code
),
this.roundToCurrencyPrecision(
paymentCollection.amount,
paymentCollection.currency_code
)
)
? PaymentCollectionStatus.AUTHORIZED ? PaymentCollectionStatus.AUTHORIZED
: PaymentCollectionStatus.PARTIALLY_AUTHORIZED : PaymentCollectionStatus.PARTIALLY_AUTHORIZED
} }
if (MathBN.eq(paymentCollection.amount, capturedAmount)) { if (
MathBN.gte(
this.roundToCurrencyPrecision(
capturedAmount,
paymentCollection.currency_code
),
this.roundToCurrencyPrecision(
paymentCollection.amount,
paymentCollection.currency_code
)
)
) {
status = PaymentCollectionStatus.COMPLETED status = PaymentCollectionStatus.COMPLETED
completedAt = new Date() completedAt = new Date()
} }