fix(payment): round curency precision (#12803)
This commit is contained in:
1 parent
93cf79cb0f
commit
ba1e6595b7
4 files changed
+151
-17
No files matched your search
@@ -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
|
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 {
|
||||||
|
|||||||
+74
-1
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user