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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ extension Decimal {
return rounded
}

public func roundedUp(to decimalPlaces: Int) -> Decimal {
var current = self
var rounded = Decimal()
NSDecimalRound(&rounded, &current, decimalPlaces, .up)
return rounded
}

private func pow10(_ n: Int) -> Decimal {
var result: Decimal = 1
for _ in 0..<n {
Expand Down
30 changes: 24 additions & 6 deletions FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,27 @@ extension FiatAmount {
}

/// This amount restated in `currency`, routed through USD the way the rate
/// table is keyed, and rounded to the target's display precision. `nil` when
/// either leg of the conversion has no rate.
public func converted(to currency: CurrencyCode, rates: [CurrencyCode: Rate]) -> FiatAmount? {
/// table is keyed, and rounded to the target's display precision — half-up
/// by default, or up when `roundingUp` so the result never falls short of
/// this amount. `nil` when either leg of the conversion has no rate.
public func converted(
to currency: CurrencyCode,
rates: [CurrencyCode: Rate],
roundingUp: Bool = false
) -> FiatAmount? {
if self.currency == currency {
return self
}
guard let ownRate = rates[self.currency] else { return nil }
let usd = convertingToUSD(rate: ownRate)
let unrounded: FiatAmount
if currency == .usd {
return usd.roundedToSmallestUnit()
unrounded = usd
} else {
guard let targetRate = rates[currency] else { return nil }
unrounded = usd.converting(to: targetRate)
}
guard let targetRate = rates[currency] else { return nil }
return usd.converting(to: targetRate).roundedToSmallestUnit()
return roundingUp ? unrounded.ceiledToSmallestUnit() : unrounded.roundedToSmallestUnit()
}
}

Expand Down Expand Up @@ -227,6 +235,16 @@ extension FiatAmount {
currency: currency
)
}

/// This value raised to its currency's smallest displayable unit (e.g.
/// $6.2533 → $6.26). For values whose defining invariant rounding down
/// would break, such as a floor that has to cover a fee.
public func ceiledToSmallestUnit() -> FiatAmount {
FiatAmount(
value: value.roundedUp(to: currency.maximumFractionDigits),
currency: currency
)
}
}

// MARK: - Description -
Expand Down
4 changes: 3 additions & 1 deletion FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ extension TipFloor {
in currency: CurrencyCode,
rates: [CurrencyCode: Rate]
) -> TipFloor? {
if let fee = recipientFee?.converted(to: currency, rates: rates), fee.isPositive {
// Rounded up: a half-up floor can land under the fee, pass `isMet`,
// and be denied by the server.
if let fee = recipientFee?.converted(to: currency, rates: rates, roundingUp: true), fee.isPositive {
return .recipientFee(fee)
}
return systemMinimum(presets: presets)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//
// Regression_6a677815fdca5cb0d6a9ef7e.swift
// FlipcashTests
//
// Server denial: "tip amount is below the recipient's chat initialization
// fee". The Start Chatting sheet submits the floor `TipFloor.toOpenDM` states,
// which is the recipient's fee converted into the entry currency. The
// conversion rounded half-up, so it could land under the fee — 600 INR at
// 95.95 INR/USD is $6.2533, stated as $6.25, which is 599.69 INR. `isMet`
// judged the entry against that same figure, so it passed locally and the
// server denied it.
//
// Fix: `toOpenDM` rounds the converted fee up, so the stated floor always
// covers the fee at the rate it was converted with.
//

import Foundation
import Testing
@testable import FlipcashCore

@Suite("Regression: 6a67781 – converted DM fee floor rounds below the fee", .bug("6a677815fdca5cb0d6a9ef7e"))
struct Regression_6a67781 {

private let usdRate = Rate(fx: 1, currency: .usd)
private let inrRate = Rate(fx: Decimal(string: "95.95")!, currency: .inr)
private let ngnRate = Rate(fx: 1326, currency: .ngn)

private var rates: [CurrencyCode: Rate] {
[.usd: usdRate, .inr: inrRate, .ngn: ngnRate]
}

private func floor(for fee: FiatAmount) -> TipFloor? {
TipFloor.toOpenDM(recipientFee: fee, presets: nil, in: .usd, rates: rates)
}

@Test("A 600 INR fee is stated as $6.26, which covers it, not $6.25")
func inrFee_floorCoversFee() throws {
let floor = try #require(floor(for: FiatAmount(value: 600, currency: .inr)))

#expect(floor.displayed == .usd(Decimal(string: "6.26")!))
#expect(floor.displayed.converting(to: inrRate).value >= 600)
}

@Test("The $6.25 the sheet used to submit no longer clears the floor locally")
func inrFee_roundedDownEntryIsRejected() throws {
let floor = try #require(floor(for: FiatAmount(value: 600, currency: .inr)))
let entered = ExchangedFiat(nativeAmount: .usd(Decimal(string: "6.25")!), rate: usdRate)

#expect(!floor.isMet(by: entered))
}

@Test("A 1500 NGN fee is stated as $1.14, which covers it, not $1.13")
func ngnFee_floorCoversFee() throws {
let floor = try #require(floor(for: FiatAmount(value: 1500, currency: .ngn)))

#expect(floor.displayed == .usd(Decimal(string: "1.14")!))
#expect(floor.displayed.converting(to: ngnRate).value >= 1500)
}

@Test("A fee that converts exactly is not bumped a cent")
func exactConversion_isUnchanged() throws {
let exactRates: [CurrencyCode: Rate] = [.usd: usdRate, .inr: Rate(fx: 100, currency: .inr)]
let floor = try #require(TipFloor.toOpenDM(
recipientFee: FiatAmount(value: 600, currency: .inr),
presets: nil,
in: .usd,
rates: exactRates
))

#expect(floor.displayed == .usd(6))
}
}
Loading