fix(payment): round curency precision (#12803)
This commit is contained in:
committed by
GitHub
parent
93cf79cb0f
commit
ba1e6595b7
6
.changeset/bright-guests-speak.md
Normal file
6
.changeset/bright-guests-speak.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@medusajs/payment": patch
|
||||
"@medusajs/utils": patch
|
||||
---
|
||||
|
||||
fix(payment): round currency decimal precision
|
||||
@@ -5,20 +5,27 @@ import { BigNumber } from "./big-number"
|
||||
|
||||
type BNInput = BigNumberInput | BigNumber
|
||||
export class MathBN {
|
||||
static convert(num: BNInput): BigNumberJS {
|
||||
static convert(num: BNInput, decimalPlaces?: number): BigNumberJS {
|
||||
if (num == null) {
|
||||
return new BigNumberJS(0)
|
||||
}
|
||||
|
||||
let num_ = num
|
||||
if (num instanceof BigNumber) {
|
||||
return num.bigNumber!
|
||||
num_ = num.bigNumber!
|
||||
} else if (num instanceof BigNumberJS) {
|
||||
return num
|
||||
num_ = num
|
||||
} 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 {
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -1032,7 +1105,7 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
|
||||
|
||||
expect(finalCollection).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "authorized",
|
||||
status: "completed",
|
||||
amount: 500,
|
||||
authorized_amount: 1000,
|
||||
captured_amount: 1000,
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import {
|
||||
AccountHolderDTO,
|
||||
BigNumberInput,
|
||||
CaptureDTO,
|
||||
Context,
|
||||
CreateAccountHolderDTO,
|
||||
CreateAccountHolderOutput,
|
||||
CreateCaptureDTO,
|
||||
CreatePaymentCollectionDTO,
|
||||
CreatePaymentMethodDTO,
|
||||
CreatePaymentSessionDTO,
|
||||
CreateRefundDTO,
|
||||
AccountHolderDTO,
|
||||
DAL,
|
||||
FilterablePaymentCollectionProps,
|
||||
FilterablePaymentMethodProps,
|
||||
FilterablePaymentProviderProps,
|
||||
FindConfig,
|
||||
InferEntityType,
|
||||
InitiatePaymentOutput,
|
||||
InternalModuleDeclaration,
|
||||
IPaymentModuleService,
|
||||
Logger,
|
||||
@@ -28,16 +31,13 @@ import {
|
||||
ProviderWebhookPayload,
|
||||
RefundDTO,
|
||||
RefundReasonDTO,
|
||||
UpdateAccountHolderDTO,
|
||||
UpdateAccountHolderOutput,
|
||||
UpdatePaymentCollectionDTO,
|
||||
UpdatePaymentDTO,
|
||||
UpdatePaymentSessionDTO,
|
||||
CreateAccountHolderDTO,
|
||||
UpsertPaymentCollectionDTO,
|
||||
WebhookActionResult,
|
||||
CreateAccountHolderOutput,
|
||||
InitiatePaymentOutput,
|
||||
UpdateAccountHolderDTO,
|
||||
UpdateAccountHolderOutput,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
BigNumber,
|
||||
@@ -152,6 +152,25 @@ export default class PaymentModuleService
|
||||
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
|
||||
createPaymentCollections(
|
||||
data: CreatePaymentCollectionDTO,
|
||||
@@ -627,6 +646,7 @@ export default class PaymentModuleService
|
||||
"payment_collection_id",
|
||||
"amount",
|
||||
"raw_amount",
|
||||
"currency_code",
|
||||
"captured_at",
|
||||
"canceled_at",
|
||||
],
|
||||
@@ -698,7 +718,12 @@ export default class PaymentModuleService
|
||||
const newCaptureAmount = new BigNumber(data.amount)
|
||||
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(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`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(
|
||||
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(
|
||||
{
|
||||
@@ -892,7 +920,7 @@ export default class PaymentModuleService
|
||||
const paymentCollection = await this.paymentCollectionService_.retrieve(
|
||||
paymentCollectionId,
|
||||
{
|
||||
select: ["amount", "raw_amount", "status"],
|
||||
select: ["amount", "raw_amount", "status", "currency_code"],
|
||||
relations: [
|
||||
"payment_sessions.amount",
|
||||
"payment_sessions.raw_amount",
|
||||
@@ -938,12 +966,32 @@ export default class PaymentModuleService
|
||||
: PaymentCollectionStatus.AWAITING
|
||||
|
||||
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.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
|
||||
completedAt = new Date()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user