From ba1e6595b74ae69f4ee066d545f8def8aff77dd3 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Tue, 24 Jun 2025 13:41:14 -0300 Subject: [PATCH] fix(payment): round curency precision (#12803) --- .changeset/bright-guests-speak.md | 6 ++ packages/core/utils/src/totals/math.ts | 17 +++-- .../services/payment-module/index.spec.ts | 75 ++++++++++++++++++- .../payment/src/services/payment-module.ts | 70 ++++++++++++++--- 4 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 .changeset/bright-guests-speak.md diff --git a/.changeset/bright-guests-speak.md b/.changeset/bright-guests-speak.md new file mode 100644 index 0000000000..be6ace10f1 --- /dev/null +++ b/.changeset/bright-guests-speak.md @@ -0,0 +1,6 @@ +--- +"@medusajs/payment": patch +"@medusajs/utils": patch +--- + +fix(payment): round currency decimal precision diff --git a/packages/core/utils/src/totals/math.ts b/packages/core/utils/src/totals/math.ts index 35eafcc5a8..458ea39102 100644 --- a/packages/core/utils/src/totals/math.ts +++ b/packages/core/utils/src/totals/math.ts @@ -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 { diff --git a/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts b/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts index 5be98bbbd6..a776647477 100644 --- a/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts +++ b/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts @@ -168,6 +168,79 @@ moduleIntegrationTestRunner({ }) ) }) + + 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({ expect(finalCollection).toEqual( expect.objectContaining({ - status: "authorized", + status: "completed", amount: 500, authorized_amount: 1000, captured_amount: 1000, diff --git a/packages/modules/payment/src/services/payment-module.ts b/packages/modules/payment/src/services/payment-module.ts index adb6558a17..28d3fd8c72 100644 --- a/packages/modules/payment/src/services/payment-module.ts +++ b/packages/modules/payment/src/services/payment-module.ts @@ -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() }