Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 14 additions & 7 deletions Sources/PerfectAuthNet/AuthNetClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
22 changes: 22 additions & 0 deletions Tests/PerfectAuthNetTests/PerfectAuthNetTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading