Skip to content

Commit dde9c26

Browse files
authored
fix(tip): round a converted DM fee floor up
The Start Chatting sheet submits the floor TipFloor.toOpenDM states, and that floor rounded the converted fee to the nearest cent. A 600 INR fee at 95.95 INR/USD is $6.2533, stated as $6.25, which is 599.69 INR. The client accepted it and the server denied it (Bugsnag 6a67781). converted(to:rates:) takes a roundingUp flag and toOpenDM is the only caller that sets it, so display conversions still round half-up. A fee that converts exactly is unchanged.
1 parent a0651a9 commit dde9c26

4 files changed

Lines changed: 106 additions & 7 deletions

File tree

‎FlipcashCore/Sources/FlipcashCore/Extensions/Decimal+Operations.swift‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ extension Decimal {
2222
return rounded
2323
}
2424

25+
public func roundedUp(to decimalPlaces: Int) -> Decimal {
26+
var current = self
27+
var rounded = Decimal()
28+
NSDecimalRound(&rounded, &current, decimalPlaces, .up)
29+
return rounded
30+
}
31+
2532
private func pow10(_ n: Int) -> Decimal {
2633
var result: Decimal = 1
2734
for _ in 0..<n {

‎FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,19 +79,27 @@ extension FiatAmount {
7979
}
8080

8181
/// This amount restated in `currency`, routed through USD the way the rate
82-
/// table is keyed, and rounded to the target's display precision. `nil` when
83-
/// either leg of the conversion has no rate.
84-
public func converted(to currency: CurrencyCode, rates: [CurrencyCode: Rate]) -> FiatAmount? {
82+
/// table is keyed, and rounded to the target's display precision — half-up
83+
/// by default, or up when `roundingUp` so the result never falls short of
84+
/// this amount. `nil` when either leg of the conversion has no rate.
85+
public func converted(
86+
to currency: CurrencyCode,
87+
rates: [CurrencyCode: Rate],
88+
roundingUp: Bool = false
89+
) -> FiatAmount? {
8590
if self.currency == currency {
8691
return self
8792
}
8893
guard let ownRate = rates[self.currency] else { return nil }
8994
let usd = convertingToUSD(rate: ownRate)
95+
let unrounded: FiatAmount
9096
if currency == .usd {
91-
return usd.roundedToSmallestUnit()
97+
unrounded = usd
98+
} else {
99+
guard let targetRate = rates[currency] else { return nil }
100+
unrounded = usd.converting(to: targetRate)
92101
}
93-
guard let targetRate = rates[currency] else { return nil }
94-
return usd.converting(to: targetRate).roundedToSmallestUnit()
102+
return roundingUp ? unrounded.ceiledToSmallestUnit() : unrounded.roundedToSmallestUnit()
95103
}
96104
}
97105

@@ -227,6 +235,16 @@ extension FiatAmount {
227235
currency: currency
228236
)
229237
}
238+
239+
/// This value raised to its currency's smallest displayable unit (e.g.
240+
/// $6.2533 → $6.26). For values whose defining invariant rounding down
241+
/// would break, such as a floor that has to cover a fee.
242+
public func ceiledToSmallestUnit() -> FiatAmount {
243+
FiatAmount(
244+
value: value.roundedUp(to: currency.maximumFractionDigits),
245+
currency: currency
246+
)
247+
}
230248
}
231249

232250
// MARK: - Description -

‎FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ extension TipFloor {
6464
in currency: CurrencyCode,
6565
rates: [CurrencyCode: Rate]
6666
) -> TipFloor? {
67-
if let fee = recipientFee?.converted(to: currency, rates: rates), fee.isPositive {
67+
// Rounded up: a half-up floor can land under the fee, pass `isMet`,
68+
// and be denied by the server.
69+
if let fee = recipientFee?.converted(to: currency, rates: rates, roundingUp: true), fee.isPositive {
6870
return .recipientFee(fee)
6971
}
7072
return systemMinimum(presets: presets)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//
2+
// Regression_6a677815fdca5cb0d6a9ef7e.swift
3+
// FlipcashTests
4+
//
5+
// Server denial: "tip amount is below the recipient's chat initialization
6+
// fee". The Start Chatting sheet submits the floor `TipFloor.toOpenDM` states,
7+
// which is the recipient's fee converted into the entry currency. The
8+
// conversion rounded half-up, so it could land under the fee — 600 INR at
9+
// 95.95 INR/USD is $6.2533, stated as $6.25, which is 599.69 INR. `isMet`
10+
// judged the entry against that same figure, so it passed locally and the
11+
// server denied it.
12+
//
13+
// Fix: `toOpenDM` rounds the converted fee up, so the stated floor always
14+
// covers the fee at the rate it was converted with.
15+
//
16+
17+
import Foundation
18+
import Testing
19+
@testable import FlipcashCore
20+
21+
@Suite("Regression: 6a67781 – converted DM fee floor rounds below the fee", .bug("6a677815fdca5cb0d6a9ef7e"))
22+
struct Regression_6a67781 {
23+
24+
private let usdRate = Rate(fx: 1, currency: .usd)
25+
private let inrRate = Rate(fx: Decimal(string: "95.95")!, currency: .inr)
26+
private let ngnRate = Rate(fx: 1326, currency: .ngn)
27+
28+
private var rates: [CurrencyCode: Rate] {
29+
[.usd: usdRate, .inr: inrRate, .ngn: ngnRate]
30+
}
31+
32+
private func floor(for fee: FiatAmount) -> TipFloor? {
33+
TipFloor.toOpenDM(recipientFee: fee, presets: nil, in: .usd, rates: rates)
34+
}
35+
36+
@Test("A 600 INR fee is stated as $6.26, which covers it, not $6.25")
37+
func inrFee_floorCoversFee() throws {
38+
let floor = try #require(floor(for: FiatAmount(value: 600, currency: .inr)))
39+
40+
#expect(floor.displayed == .usd(Decimal(string: "6.26")!))
41+
#expect(floor.displayed.converting(to: inrRate).value >= 600)
42+
}
43+
44+
@Test("The $6.25 the sheet used to submit no longer clears the floor locally")
45+
func inrFee_roundedDownEntryIsRejected() throws {
46+
let floor = try #require(floor(for: FiatAmount(value: 600, currency: .inr)))
47+
let entered = ExchangedFiat(nativeAmount: .usd(Decimal(string: "6.25")!), rate: usdRate)
48+
49+
#expect(!floor.isMet(by: entered))
50+
}
51+
52+
@Test("A 1500 NGN fee is stated as $1.14, which covers it, not $1.13")
53+
func ngnFee_floorCoversFee() throws {
54+
let floor = try #require(floor(for: FiatAmount(value: 1500, currency: .ngn)))
55+
56+
#expect(floor.displayed == .usd(Decimal(string: "1.14")!))
57+
#expect(floor.displayed.converting(to: ngnRate).value >= 1500)
58+
}
59+
60+
@Test("A fee that converts exactly is not bumped a cent")
61+
func exactConversion_isUnchanged() throws {
62+
let exactRates: [CurrencyCode: Rate] = [.usd: usdRate, .inr: Rate(fx: 100, currency: .inr)]
63+
let floor = try #require(TipFloor.toOpenDM(
64+
recipientFee: FiatAmount(value: 600, currency: .inr),
65+
presets: nil,
66+
in: .usd,
67+
rates: exactRates
68+
))
69+
70+
#expect(floor.displayed == .usd(6))
71+
}
72+
}

0 commit comments

Comments
 (0)