fix: use backend-authoritative change amount for cash transactions

Instead of calculating change client-side (which doesn't account for
balance applied, fees, etc.), the app now uses the Change value returned
by the /tasks/complete.php endpoint after processing.

Changes:
- APIService.completeTask now returns CashCompletionResult with backend values
- Added CashCompletionResult struct (cashReceived, orderTotal, change, fees, routing)
- CashCollectionSheet shows confirmed backend change after completion
- Added ratingRequired error case to APIError enum
- Client-side estimate still shown as preview before confirmation
This commit is contained in:
Schwifty 2026-03-23 18:34:18 +00:00
parent 0639fe12c2
commit 46e008b20b
2 changed files with 82 additions and 7 deletions

View file

@ -18,6 +18,7 @@ enum APIError: LocalizedError {
case serverError(String) case serverError(String)
case unauthorized case unauthorized
case networkError(String) case networkError(String)
case ratingRequired(customerUserId: Int)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@ -27,6 +28,7 @@ enum APIError: LocalizedError {
case .serverError(let msg): return msg case .serverError(let msg): return msg
case .unauthorized: return "Unauthorized" case .unauthorized: return "Unauthorized"
case .networkError(let msg): return msg case .networkError(let msg): return msg
case .ratingRequired: return "Customer rating is required before completing this task"
} }
} }
} }
@ -54,6 +56,22 @@ struct ChatMessagesResult {
let chatClosed: Bool let chatClosed: Bool
} }
/// Backend-authoritative cash completion result.
/// All monetary values are formatted strings (e.g. "12.50") from the server.
struct CashCompletionResult {
let cashReceived: String
let orderTotal: String
let change: String
let customerFee: String?
let businessFee: String?
let cashRoutedTo: String // "worker" or "business"
let balanceApplied: String?
var changeDollars: Double {
Double(change) ?? 0
}
}
// MARK: - API Service // MARK: - API Service
actor APIService { actor APIService {
@ -472,7 +490,9 @@ actor APIService {
return arr.map { WorkTask(json: $0) } return arr.map { WorkTask(json: $0) }
} }
func completeTask(taskId: Int, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws { /// Complete a task. Returns CashCompletionResult when cashReceivedCents is provided.
@discardableResult
func completeTask(taskId: Int, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws -> CashCompletionResult? {
var payload: [String: Any] = [ var payload: [String: Any] = [
"TaskID": taskId, "TaskID": taskId,
"UserID": userId ?? 0 "UserID": userId ?? 0
@ -485,8 +505,27 @@ actor APIService {
} }
let json = try await postJSON("/tasks/complete.php", payload: payload) let json = try await postJSON("/tasks/complete.php", payload: payload)
guard ok(json) else { guard ok(json) else {
throw APIError.serverError("Failed to complete task: \(err(json))") let errorMsg = err(json)
if errorMsg == "rating_required" {
let customerUserId = json["CustomerUserID"] as? Int ?? 0
throw APIError.ratingRequired(customerUserId: customerUserId)
}
throw APIError.serverError("Failed to complete task: \(errorMsg)")
} }
// Parse cash completion response from backend (authoritative values)
if let _ = cashReceivedCents, json["CashProcessed"] as? Bool == true {
return CashCompletionResult(
cashReceived: json["CashReceived"] as? String ?? "0.00",
orderTotal: json["OrderTotal"] as? String ?? "0.00",
change: json["Change"] as? String ?? "0.00",
customerFee: json["CustomerFee"] as? String,
businessFee: json["BusinessFee"] as? String,
cashRoutedTo: json["CashRoutedTo"] as? String ?? "worker",
balanceApplied: json["BalanceApplied"] as? String
)
}
return nil
} }
func closeChat(taskId: Int) async throws { func closeChat(taskId: Int) async throws {

View file

@ -785,6 +785,7 @@ struct CashCollectionSheet: View {
@State private var cashReceivedText = "" @State private var cashReceivedText = ""
@State private var isProcessing = false @State private var isProcessing = false
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var completionResult: CashCompletionResult?
private var isAdmin: Bool { roleId >= 2 } private var isAdmin: Bool { roleId >= 2 }
@ -795,7 +796,8 @@ struct CashCollectionSheet: View {
return Int(round(dollars * 100)) return Int(round(dollars * 100))
} }
private var changeDue: Double? { /// Client-side estimate shown before confirmation (preview only)
private var estimatedChangeDue: Double? {
guard let receivedCents = cashReceivedCents else { return nil } guard let receivedCents = cashReceivedCents else { return nil }
let change = Double(receivedCents - orderTotalCents) / 100 let change = Double(receivedCents - orderTotalCents) / 100
return change >= 0 ? change : nil return change >= 0 ? change : nil
@ -839,8 +841,35 @@ struct CashCollectionSheet: View {
.cornerRadius(12) .cornerRadius(12)
} }
// Change display // Change display backend-authoritative after completion, estimate before
if let change = changeDue { if let result = completionResult {
// Show confirmed change from backend
VStack(spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.payfritGreen)
Text("Change Due:")
.font(.body.weight(.medium))
Spacer()
Text("$\(result.change)")
.font(.title3.bold())
.foregroundColor(.payfritGreen)
}
if let balanceApplied = result.balanceApplied {
HStack {
Text("Balance applied:")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text("$\(balanceApplied)")
.font(.caption.weight(.medium))
}
}
}
.padding(16)
.background(Color.payfritGreen.opacity(0.15))
.cornerRadius(12)
} else if let change = estimatedChangeDue {
HStack { HStack {
Image(systemName: "arrow.uturn.left.circle.fill") Image(systemName: "arrow.uturn.left.circle.fill")
.foregroundColor(.payfritGreen) .foregroundColor(.payfritGreen)
@ -905,7 +934,7 @@ struct CashCollectionSheet: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(Color(red: 0.13, green: 0.55, blue: 0.13)) .tint(Color(red: 0.13, green: 0.55, blue: 0.13))
.disabled(!isValid || isProcessing) .disabled(!isValid || isProcessing || completionResult != nil)
.padding(.bottom, 16) .padding(.bottom, 16)
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
@ -927,7 +956,14 @@ struct CashCollectionSheet: View {
Task { Task {
do { do {
try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cents) let result = try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cents)
if let result = result {
// Show backend-authoritative change before dismissing
completionResult = result
isProcessing = false
// Brief pause so worker can see the confirmed change amount
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
dismiss() dismiss()
onComplete() onComplete()
} catch { } catch {