From 46e008b20bc67668c89a7ce03717a37ce66688a7 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Mon, 23 Mar 2026 18:34:18 +0000 Subject: [PATCH] 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 --- PayfritWorks/Services/APIService.swift | 43 ++++++++++++++++++++- PayfritWorks/Views/TaskDetailScreen.swift | 46 ++++++++++++++++++++--- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/PayfritWorks/Services/APIService.swift b/PayfritWorks/Services/APIService.swift index fd15218..7d83ac7 100644 --- a/PayfritWorks/Services/APIService.swift +++ b/PayfritWorks/Services/APIService.swift @@ -18,6 +18,7 @@ enum APIError: LocalizedError { case serverError(String) case unauthorized case networkError(String) + case ratingRequired(customerUserId: Int) var errorDescription: String? { switch self { @@ -27,6 +28,7 @@ enum APIError: LocalizedError { case .serverError(let msg): return msg case .unauthorized: return "Unauthorized" 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 } +/// 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 actor APIService { @@ -472,7 +490,9 @@ actor APIService { 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] = [ "TaskID": taskId, "UserID": userId ?? 0 @@ -485,8 +505,27 @@ actor APIService { } let json = try await postJSON("/tasks/complete.php", payload: payload) 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 { diff --git a/PayfritWorks/Views/TaskDetailScreen.swift b/PayfritWorks/Views/TaskDetailScreen.swift index ed64745..48d32b4 100644 --- a/PayfritWorks/Views/TaskDetailScreen.swift +++ b/PayfritWorks/Views/TaskDetailScreen.swift @@ -785,6 +785,7 @@ struct CashCollectionSheet: View { @State private var cashReceivedText = "" @State private var isProcessing = false @State private var errorMessage: String? + @State private var completionResult: CashCompletionResult? private var isAdmin: Bool { roleId >= 2 } @@ -795,7 +796,8 @@ struct CashCollectionSheet: View { 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 } let change = Double(receivedCents - orderTotalCents) / 100 return change >= 0 ? change : nil @@ -839,8 +841,35 @@ struct CashCollectionSheet: View { .cornerRadius(12) } - // Change display - if let change = changeDue { + // Change display — backend-authoritative after completion, estimate before + 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 { Image(systemName: "arrow.uturn.left.circle.fill") .foregroundColor(.payfritGreen) @@ -905,7 +934,7 @@ struct CashCollectionSheet: View { } .buttonStyle(.borderedProminent) .tint(Color(red: 0.13, green: 0.55, blue: 0.13)) - .disabled(!isValid || isProcessing) + .disabled(!isValid || isProcessing || completionResult != nil) .padding(.bottom, 16) } .padding(.horizontal, 24) @@ -927,7 +956,14 @@ struct CashCollectionSheet: View { Task { 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() onComplete() } catch {