From 252307319f5e446d356715c0c1d08a566ce138a6 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 28 Oct 2026 16:14:39 -0500 Subject: [PATCH] Round amounts to the cent the same way on macOS and Linux formatAmount used NumberFormatter, whose maximumFractionDigits is ignored on Linux, so 49.995 went to the gateway as "49.995" there while macOS sent "50.00". Round with NSDecimalRound (half-to-even, matching what macOS already did) and build the string from Decimal.description, so the output no longer depends on Foundation's formatter. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++++ Sources/PerfectAuthNet/AuthNetClient.swift | 21 ++++++++++++------ .../PerfectAuthNetTests.swift | 22 +++++++++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f5c107c..a5aefd5 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,11 @@ try await client.refund(transactionID: txnID, amount: 49.99, try await client.void(transactionID: txnID) ``` +Amounts are `Decimal` and are sent with exactly two decimal places (`40` becomes `"40.00"`). An +amount with more than two decimal places is rounded to the cent, half-to-even, the same way on +macOS and Linux: `49.995` is sent as `50.00` and `49.985` as `49.98`. If your prices can have +fractions of a cent, round them yourself first with the rule you want. + ## Result ```swift diff --git a/Sources/PerfectAuthNet/AuthNetClient.swift b/Sources/PerfectAuthNet/AuthNetClient.swift index faebb79..054b2ea 100644 --- a/Sources/PerfectAuthNet/AuthNetClient.swift +++ b/Sources/PerfectAuthNet/AuthNetClient.swift @@ -193,13 +193,20 @@ public struct AuthNetClient: Sendable { } /// Formats a `Decimal` as a fixed two-decimal string (`"49.99"`), locale-independent. + /// Amounts with more than two decimal places are rounded half-to-even (`49.995` → `"50.00"`, + /// `49.985` → `"49.98"`), the same on every platform. static func formatAmount(_ amount: Decimal) -> String { - let formatter = NumberFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.numberStyle = .decimal - formatter.usesGroupingSeparator = false - formatter.minimumFractionDigits = 2 - formatter.maximumFractionDigits = 2 - return formatter.string(from: NSDecimalNumber(decimal: amount)) ?? "0.00" + var value = amount + var rounded = Decimal() + NSDecimalRound(&rounded, &value, 2, .bankers) + // `Decimal.description` always uses "." and never groups or uses exponents. + var text = rounded.description + let fraction = text.firstIndex(of: ".").map { text.distance(from: $0, to: text.endIndex) - 1 } + switch fraction { + case nil: text += ".00" + case 1?: text += "0" + default: break + } + return text } } diff --git a/Tests/PerfectAuthNetTests/PerfectAuthNetTests.swift b/Tests/PerfectAuthNetTests/PerfectAuthNetTests.swift index eb2c8af..19137e6 100644 --- a/Tests/PerfectAuthNetTests/PerfectAuthNetTests.swift +++ b/Tests/PerfectAuthNetTests/PerfectAuthNetTests.swift @@ -115,6 +115,28 @@ struct PerfectAuthNetTests { #expect(AuthNetClient.formatAmount(Decimal(string: "0")!) == "0.00") } + @Test(arguments: [ + ("49.995", "50.00"), // half-to-even rounds up to the even cent + ("49.985", "49.98"), // and down when the cent is already even + ("49.9951", "50.00"), + ("49.994", "49.99"), + ("0.004", "0.00"), + ("0.005", "0.00"), + ("0.015", "0.02"), + ("1234567.891", "1234567.89"), + ("100000000000000000000", "100000000000000000000.00"), + ("-12.345", "-12.34"), + ]) + func amountFormattingRoundsToCents(input: String, expected: String) { + #expect(AuthNetClient.formatAmount(Decimal(string: input)!) == expected) + } + + @Test func amountFormattingIgnoresCurrentLocale() { + // Decimal(string:locale:) with a comma locale still yields the same value; the output must use ".". + let amount = Decimal(string: "1234,5", locale: Locale(identifier: "de_DE"))! + #expect(AuthNetClient.formatAmount(amount) == "1234.50") + } + @Test func stripBOMRemovesLeadingMarker() { let withBOM = Data([0xEF, 0xBB, 0xBF]) + Data("{}".utf8) #expect(AuthNetClient.stripBOM(withBOM) == Data("{}".utf8))