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))