Skip to content

Commit 0c5aa17

Browse files
fix(payment): round curency precision
1 parent 85bc396 commit 0c5aa17

4 files changed

Lines changed: 151 additions & 17 deletions

File tree

.changeset/bright-guests-speak.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@medusajs/payment": patch
3+
"@medusajs/utils": patch
4+
---
5+
6+
fix(payment): round currency decimal precision

packages/core/utils/src/totals/math.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,27 @@ import { BigNumber } from "./big-number"
55

66
type BNInput = BigNumberInput | BigNumber
77
export class MathBN {
8-
static convert(num: BNInput): BigNumberJS {
8+
static convert(num: BNInput, decimalPlaces?: number): BigNumberJS {
99
if (num == null) {
1010
return new BigNumberJS(0)
1111
}
1212

13+
let num_ = num
1314
if (num instanceof BigNumber) {
14-
return num.bigNumber!
15+
num_ = num.bigNumber!
1516
} else if (num instanceof BigNumberJS) {
16-
return num
17+
num_ = num
1718
} else if (isDefined((num as BigNumberRawValue)?.value)) {
18-
return new BigNumberJS((num as BigNumberRawValue).value)
19+
num_ = new BigNumberJS((num as BigNumberRawValue).value)
20+
} else {
21+
num_ = new BigNumberJS(num as BigNumberJS | number)
1922
}
2023

21-
return new BigNumberJS(num as BigNumberJS | number)
24+
if (decimalPlaces) {
25+
num_ = num_.decimalPlaces(decimalPlaces)
26+
}
27+
28+
return num_
2229
}
2330

2431
static add(...nums: BNInput[]): BigNumberJS {

packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,79 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
168168
})
169169
)
170170
})
171+
172+
it("complete payment flow successfully when rounded numbers are equal", async () => {
173+
let paymentCollection = await service.createPaymentCollections({
174+
currency_code: "usd",
175+
amount: 200.129,
176+
})
177+
178+
const paymentSession = await service.createPaymentSession(
179+
paymentCollection.id,
180+
{
181+
provider_id: "pp_system_default",
182+
amount: 200.129,
183+
currency_code: "usd",
184+
data: {},
185+
context: {
186+
customer: { id: "cus-id-1", email: "new@test.tsst" },
187+
},
188+
}
189+
)
190+
191+
const payment = await service.authorizePaymentSession(
192+
paymentSession.id,
193+
{}
194+
)
195+
196+
await service.capturePayment({
197+
amount: 200.13, // rounded from payment provider
198+
payment_id: payment.id,
199+
})
200+
201+
await service.completePaymentCollections(paymentCollection.id)
202+
203+
paymentCollection = await service.retrievePaymentCollection(
204+
paymentCollection.id,
205+
{ relations: ["payment_sessions", "payments.captures"] }
206+
)
207+
208+
expect(paymentCollection).toEqual(
209+
expect.objectContaining({
210+
id: expect.any(String),
211+
currency_code: "usd",
212+
amount: 200.129,
213+
authorized_amount: 200.129,
214+
captured_amount: 200.13,
215+
status: "completed",
216+
deleted_at: null,
217+
completed_at: expect.any(Date),
218+
payment_sessions: [
219+
expect.objectContaining({
220+
id: expect.any(String),
221+
currency_code: "usd",
222+
amount: 200.129,
223+
provider_id: "pp_system_default",
224+
status: "authorized",
225+
authorized_at: expect.any(Date),
226+
}),
227+
],
228+
payments: [
229+
expect.objectContaining({
230+
id: expect.any(String),
231+
amount: 200.129,
232+
currency_code: "usd",
233+
provider_id: "pp_system_default",
234+
captures: [
235+
expect.objectContaining({
236+
amount: 200.13,
237+
}),
238+
],
239+
}),
240+
],
241+
})
242+
)
243+
})
171244
})
172245

173246
describe("PaymentCollection", () => {
@@ -1032,7 +1105,7 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
10321105

10331106
expect(finalCollection).toEqual(
10341107
expect.objectContaining({
1035-
status: "authorized",
1108+
status: "completed",
10361109
amount: 500,
10371110
authorized_amount: 1000,
10381111
captured_amount: 1000,

packages/modules/payment/src/services/payment-module.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import {
2+
AccountHolderDTO,
23
BigNumberInput,
34
CaptureDTO,
45
Context,
6+
CreateAccountHolderDTO,
7+
CreateAccountHolderOutput,
58
CreateCaptureDTO,
69
CreatePaymentCollectionDTO,
710
CreatePaymentMethodDTO,
811
CreatePaymentSessionDTO,
912
CreateRefundDTO,
10-
AccountHolderDTO,
1113
DAL,
1214
FilterablePaymentCollectionProps,
1315
FilterablePaymentMethodProps,
1416
FilterablePaymentProviderProps,
1517
FindConfig,
1618
InferEntityType,
19+
InitiatePaymentOutput,
1720
InternalModuleDeclaration,
1821
IPaymentModuleService,
1922
Logger,
@@ -28,16 +31,13 @@ import {
2831
ProviderWebhookPayload,
2932
RefundDTO,
3033
RefundReasonDTO,
34+
UpdateAccountHolderDTO,
35+
UpdateAccountHolderOutput,
3136
UpdatePaymentCollectionDTO,
3237
UpdatePaymentDTO,
3338
UpdatePaymentSessionDTO,
34-
CreateAccountHolderDTO,
3539
UpsertPaymentCollectionDTO,
3640
WebhookActionResult,
37-
CreateAccountHolderOutput,
38-
InitiatePaymentOutput,
39-
UpdateAccountHolderDTO,
40-
UpdateAccountHolderOutput,
4141
} from "@medusajs/framework/types"
4242
import {
4343
BigNumber,
@@ -152,6 +152,25 @@ export default class PaymentModuleService
152152
return joinerConfig
153153
}
154154

155+
protected roundToCurrencyPrecision(
156+
amount: BigNumberInput,
157+
currencyCode: string
158+
): BigNumberInput {
159+
let precision: number | undefined = undefined
160+
try {
161+
const formatted = Intl.NumberFormat(undefined, {
162+
style: "currency",
163+
currency: currencyCode,
164+
}).format(0.1111111)
165+
166+
precision = formatted.split(".")[1].length
167+
} catch {
168+
// Unknown currency, keep the full precision
169+
}
170+
171+
return MathBN.convert(amount, precision)
172+
}
173+
155174
// @ts-expect-error
156175
createPaymentCollections(
157176
data: CreatePaymentCollectionDTO,
@@ -623,6 +642,7 @@ export default class PaymentModuleService
623642
"payment_collection_id",
624643
"amount",
625644
"raw_amount",
645+
"currency_code",
626646
"captured_at",
627647
"canceled_at",
628648
],
@@ -694,7 +714,12 @@ export default class PaymentModuleService
694714
const newCaptureAmount = new BigNumber(data.amount)
695715
const remainingToCapture = MathBN.sub(authorizedAmount, capturedAmount)
696716

697-
if (MathBN.gt(newCaptureAmount, remainingToCapture)) {
717+
if (
718+
MathBN.gt(
719+
this.roundToCurrencyPrecision(newCaptureAmount, payment.currency_code),
720+
this.roundToCurrencyPrecision(remainingToCapture, payment.currency_code)
721+
)
722+
) {
698723
throw new MedusaError(
699724
MedusaError.Types.INVALID_DATA,
700725
`You cannot capture more than the authorized amount substracted by what is already captured.`
@@ -705,7 +730,10 @@ export default class PaymentModuleService
705730
const totalCaptured = MathBN.convert(
706731
MathBN.add(capturedAmount, newCaptureAmount)
707732
)
708-
const isFullyCaptured = MathBN.gte(totalCaptured, authorizedAmount)
733+
const isFullyCaptured = MathBN.gte(
734+
this.roundToCurrencyPrecision(totalCaptured, payment.currency_code),
735+
this.roundToCurrencyPrecision(authorizedAmount, payment.currency_code)
736+
)
709737

710738
const capture = await this.captureService_.create(
711739
{
@@ -888,7 +916,7 @@ export default class PaymentModuleService
888916
const paymentCollection = await this.paymentCollectionService_.retrieve(
889917
paymentCollectionId,
890918
{
891-
select: ["amount", "raw_amount", "status"],
919+
select: ["amount", "raw_amount", "status", "currency_code"],
892920
relations: [
893921
"payment_sessions.amount",
894922
"payment_sessions.raw_amount",
@@ -934,12 +962,32 @@ export default class PaymentModuleService
934962
: PaymentCollectionStatus.AWAITING
935963

936964
if (MathBN.gt(authorizedAmount, 0)) {
937-
status = MathBN.gte(authorizedAmount, paymentCollection.amount)
965+
status = MathBN.gte(
966+
this.roundToCurrencyPrecision(
967+
authorizedAmount,
968+
paymentCollection.currency_code
969+
),
970+
this.roundToCurrencyPrecision(
971+
paymentCollection.amount,
972+
paymentCollection.currency_code
973+
)
974+
)
938975
? PaymentCollectionStatus.AUTHORIZED
939976
: PaymentCollectionStatus.PARTIALLY_AUTHORIZED
940977
}
941978

942-
if (MathBN.eq(paymentCollection.amount, capturedAmount)) {
979+
if (
980+
MathBN.gte(
981+
this.roundToCurrencyPrecision(
982+
capturedAmount,
983+
paymentCollection.currency_code
984+
),
985+
this.roundToCurrencyPrecision(
986+
paymentCollection.amount,
987+
paymentCollection.currency_code
988+
)
989+
)
990+
) {
943991
status = PaymentCollectionStatus.COMPLETED
944992
completedAt = new Date()
945993
}

0 commit comments

Comments
 (0)