Skip to content

Commit d7ca39c

Browse files
authored
Bookings: Implement mark as paid (#16338)
2 parents 1b4908c + 99f8b9a commit d7ca39c

File tree

8 files changed

+232
-44
lines changed

8 files changed

+232
-44
lines changed

Modules/Sources/Yosemite/Actions/BookingAction.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,14 @@ public enum BookingAction: Action {
8181
case cancelBooking(siteID: Int64,
8282
bookingID: Int64,
8383
onCompletion: (Error?) -> Void)
84+
85+
/// Marks a booking as paid by updating its status to paid.
86+
///
87+
/// - Parameter siteID: The site ID of the booking.
88+
/// - Parameter bookingID: The ID of the booking to be marked as paid.
89+
/// - Parameter onCompletion: called when the operation completes, returns an error in case of a failure.
90+
///
91+
case markBookingAsPaid(siteID: Int64,
92+
bookingID: Int64,
93+
onCompletion: (Error?) -> Void)
8494
}

Modules/Sources/Yosemite/Stores/BookingStore.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ public class BookingStore: Store {
7676
bookingID: bookingID,
7777
onCompletion: onCompletion
7878
)
79+
case .markBookingAsPaid(let siteID, let bookingID, let onCompletion):
80+
markBookingAsPaid(
81+
siteID: siteID,
82+
bookingID: bookingID,
83+
onCompletion: onCompletion
84+
)
7985
}
8086
}
8187
}
@@ -384,6 +390,36 @@ private extension BookingStore {
384390
}
385391
}
386392
}
393+
394+
/// Marks a booking as paid by updating its status to paid.
395+
func markBookingAsPaid(
396+
siteID: Int64,
397+
bookingID: Int64,
398+
onCompletion: @escaping (Error?) -> Void
399+
) {
400+
Task { @MainActor in
401+
do {
402+
if let remoteBooking = try await remote.updateBooking(
403+
from: siteID,
404+
bookingID: bookingID,
405+
attendanceStatus: nil,
406+
bookingStatus: .paid
407+
) {
408+
await upsertStoredBookingsInBackground(
409+
readOnlyBookings: [remoteBooking],
410+
readOnlyOrders: [],
411+
siteID: siteID
412+
)
413+
414+
onCompletion(nil)
415+
} else {
416+
return onCompletion(UpdateBookingStatusError.missingRemoteBooking)
417+
}
418+
} catch {
419+
onCompletion(error)
420+
}
421+
}
422+
}
387423
}
388424

389425

Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,112 @@ struct BookingStoreTests {
849849
#expect(error != nil)
850850
}
851851

852+
// MARK: - markBookingAsPaid
853+
854+
@Test func markBookingAsPaid_updates_local_booking_to_paid_status() async throws {
855+
// Given
856+
let booking = Booking.fake().copy(
857+
siteID: sampleSiteID,
858+
bookingID: 1,
859+
statusKey: "unpaid"
860+
)
861+
storeBooking(booking)
862+
863+
let paidBooking = booking.copy(statusKey: "paid")
864+
remote.whenUpdatingBooking(thenReturn: .success(paidBooking))
865+
let store = BookingStore(dispatcher: Dispatcher(),
866+
storageManager: storageManager,
867+
network: network,
868+
remote: remote,
869+
ordersRemote: ordersRemote)
870+
871+
// When
872+
let error = await withCheckedContinuation { continuation in
873+
store.onAction(
874+
BookingAction.markBookingAsPaid(
875+
siteID: sampleSiteID,
876+
bookingID: 1,
877+
onCompletion: { error in
878+
continuation.resume(returning: error)
879+
}
880+
)
881+
)
882+
}
883+
884+
// Then
885+
#expect(error == nil)
886+
let storedBooking = try #require(viewStorage.loadBooking(siteID: sampleSiteID, bookingID: 1))
887+
#expect(storedBooking.statusKey == "paid")
888+
}
889+
890+
@Test func markBookingAsPaid_returns_error_on_remote_failure() async throws {
891+
// Given
892+
let booking = Booking.fake().copy(
893+
siteID: sampleSiteID,
894+
bookingID: 1,
895+
statusKey: "unpaid"
896+
)
897+
storeBooking(booking)
898+
899+
remote.whenUpdatingBooking(thenReturn: .failure(NetworkError.timeout()))
900+
let store = BookingStore(dispatcher: Dispatcher(),
901+
storageManager: storageManager,
902+
network: network,
903+
remote: remote,
904+
ordersRemote: ordersRemote)
905+
906+
// When
907+
let error = await withCheckedContinuation { continuation in
908+
store.onAction(
909+
BookingAction.markBookingAsPaid(
910+
siteID: sampleSiteID,
911+
bookingID: 1,
912+
onCompletion: { error in
913+
continuation.resume(returning: error)
914+
}
915+
)
916+
)
917+
}
918+
919+
// Then
920+
#expect(error != nil)
921+
let networkError = error as? NetworkError
922+
#expect(networkError == .timeout())
923+
}
924+
925+
@Test func markBookingAsPaid_returns_error_when_remote_booking_is_missing() async throws {
926+
// Given
927+
let booking = Booking.fake().copy(
928+
siteID: sampleSiteID,
929+
bookingID: 1,
930+
statusKey: "unpaid"
931+
)
932+
storeBooking(booking)
933+
934+
remote.whenUpdatingBooking(thenReturn: .success(nil))
935+
let store = BookingStore(dispatcher: Dispatcher(),
936+
storageManager: storageManager,
937+
network: network,
938+
remote: remote,
939+
ordersRemote: ordersRemote)
940+
941+
// When
942+
let error = await withCheckedContinuation { continuation in
943+
store.onAction(
944+
BookingAction.markBookingAsPaid(
945+
siteID: sampleSiteID,
946+
bookingID: 1,
947+
onCompletion: { error in
948+
continuation.resume(returning: error)
949+
}
950+
)
951+
)
952+
}
953+
954+
// Then
955+
#expect(error != nil)
956+
}
957+
852958
// MARK: - synchronizeResources
853959

854960
@Test func synchronizeResources_returns_false_for_hasNextPage_when_number_of_retrieved_results_is_zero() async throws {

WooCommerce/Classes/Extensions/Booking+Helpers.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ extension Booking {
1919
.joined(separator: "")
2020
}
2121

22+
var isEligibleForMarkAsPaid: Bool {
23+
bookingStatus == .unpaid
24+
}
25+
2226
var hasAssociatedOrder: Bool {
2327
return orderID > 0
2428
}

WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,38 @@ extension BookingDetailsViewModel {
300300
}
301301
}
302302

303+
/// Mark booking as paid
304+
extension BookingDetailsViewModel {
305+
var shouldShowMarkAsPaid: Bool {
306+
booking.isEligibleForMarkAsPaid
307+
}
308+
309+
@MainActor
310+
func markBookingAsPaid() async throws {
311+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
312+
stores.dispatch(BookingAction.markBookingAsPaid(siteID: booking.siteID, bookingID: booking.bookingID) { error in
313+
if let error {
314+
continuation.resume(throwing: error)
315+
} else {
316+
continuation.resume(returning: ())
317+
}
318+
})
319+
}
320+
}
321+
322+
func displayMarkingAsPaidErrorNotice(onRetry: @escaping () -> Void) {
323+
let text = String.localizedStringWithFormat(
324+
Localization.bookingMarkAsPaidFailedMessage,
325+
booking.bookingID
326+
)
327+
self.notice = Notice(
328+
message: text,
329+
feedbackType: .error,
330+
actionTitle: Localization.retryActionTitle
331+
) { onRetry() }
332+
}
333+
}
334+
303335
private extension BookingDetailsViewModel {
304336
@MainActor
305337
func fetchResource() async -> BookingResource? {
@@ -436,18 +468,26 @@ private extension BookingDetailsViewModel {
436468
)
437469

438470
static let bookingAttendanceStatusUpdateFailedMessage = NSLocalizedString(
439-
"BookingDetailsView.attendanceStatus.updateFailed.message",
440-
value: "Unable to change attendance status of Booking #%1$d",
471+
"BookingDetailsView.attendanceStatus.failureMessage.",
472+
value: "Unable to change attendance status of Booking #%1$d.",
441473
comment: "Content of error presented when updating the attendance status of a Booking fails. "
442474
+ "It reads: Unable to change status of Booking #{Booking number}. "
443475
+ "Parameters: %1$d - Booking number"
444476
)
445477

446478
static let bookingCancellationFailedMessage = NSLocalizedString(
447479
"BookingDetailsView.cancellation.failureMessage",
448-
value: "Unable to cancel Booking #%1$d",
480+
value: "Unable to cancel Booking #%1$d.",
481+
comment: "Content of error presented when cancelling a Booking fails. "
482+
+ "It reads: Unable to cancel Booking #{Booking number}. "
483+
+ "Parameters: %1$d - Booking number"
484+
)
485+
486+
static let bookingMarkAsPaidFailedMessage = NSLocalizedString(
487+
"BookingDetailsView.markAsPaid.failureMessage",
488+
value: "Unable to mark Booking #%1$d as paid.",
449489
comment: "Content of error presented when cancelling a Booking fails. "
450-
+ "It reads: Unable cancel Booking #{Booking number}. "
490+
+ "It reads: Unable to mark Booking #{Booking number} as paid. "
451491
+ "Parameters: %1$d - Booking number"
452492
)
453493

WooCommerce/Classes/ViewModels/Booking Details/PaymentContent.swift

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ extension BookingDetailsViewModel {
2727
]
2828

2929
actions = [
30-
.markAsPaid,
31-
.issueRefund
32-
] + (booking.hasAssociatedOrder ? [.viewOrder] : [])
30+
booking.isEligibleForMarkAsPaid ? .markAsPaid : nil,
31+
booking.hasAssociatedOrder ? .viewOrder : nil
32+
].compactMap { $0 }
3333
}
3434
}
3535
}
@@ -83,7 +83,6 @@ extension BookingDetailsViewModel.PaymentContent.Amount.AmountType {
8383
extension BookingDetailsViewModel.PaymentContent {
8484
enum Action: String, Identifiable {
8585
case markAsPaid
86-
case issueRefund
8786
case viewOrder
8887

8988
var id: String {
@@ -97,21 +96,10 @@ extension BookingDetailsViewModel.PaymentContent.Action {
9796
switch self {
9897
case .markAsPaid:
9998
return Localization.paymentMarkAsPaidButtonTitle
100-
case .issueRefund:
101-
return Localization.paymentIssueRefundButtonTitle
10299
case .viewOrder:
103100
return Localization.paymentViewOrderButtonTitle
104101
}
105102
}
106-
107-
var isEmphasized: Bool {
108-
switch self {
109-
case .markAsPaid:
110-
return true
111-
case .issueRefund, .viewOrder:
112-
return false
113-
}
114-
}
115103
}
116104

117105
private enum Localization {
@@ -145,12 +133,6 @@ private enum Localization {
145133
comment: "Title for 'Mark as paid' button in payment section in booking details view."
146134
)
147135

148-
static let paymentIssueRefundButtonTitle = NSLocalizedString(
149-
"BookingDetailsView.payment.issueRefund.title",
150-
value: "Issue refund",
151-
comment: "Title for 'Issue refund' button in payment section in booking details view."
152-
)
153-
154136
static let paymentViewOrderButtonTitle = NSLocalizedString(
155137
"BookingDetailsView.payment.viewOrder.title",
156138
value: "View order",

WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ struct BookingDetailsView: View {
88
@State private var showingStatusSheet = false
99
@State private var showingCancelAlert = false
1010
@State private var cancellingBooking = false
11+
@State private var markingAsPaid = false
1112
@State private var notice: Notice?
1213

1314
@ObservedObject private var viewModel: BookingDetailsViewModel
@@ -62,13 +63,15 @@ struct BookingDetailsView: View {
6263
}
6364
.confirmationDialog("", isPresented: $showingOptions, titleVisibility: .hidden) {
6465
Button(Localization.markAsPaid) {
65-
print("On mark as paid tap")
66+
markBookingAsPaid()
6667
}
67-
if viewModel.isViewOrderAvailable {
68-
Button(Localization.viewOrder) {
69-
viewModel.navigateToOrderDetails()
70-
}
68+
.renderedIf(viewModel.shouldShowMarkAsPaid)
69+
70+
Button(Localization.viewOrder) {
71+
viewModel.navigateToOrderDetails()
7172
}
73+
.renderedIf(viewModel.isViewOrderAvailable)
74+
7275
Button(Localization.cancelBookingAction, role: .destructive) {
7376
showingCancelAlert = true
7477
}
@@ -221,20 +224,15 @@ private extension BookingDetailsView {
221224

222225
VStack(alignment: .leading, spacing: Layout.contentVerticalPadding) {
223226
ForEach(content.actions) { action in
224-
Button {
225-
if action == .viewOrder {
227+
switch action {
228+
case .viewOrder:
229+
Button(action.buttonTitle) {
226230
viewModel.navigateToOrderDetails()
227-
} else {
228-
/// On action tap
229231
}
230-
} label: {
231-
Text(action.buttonTitle)
232-
}
233-
.if(action.isEmphasized) {
234-
$0.buttonStyle(PrimaryButtonStyle())
235-
}
236-
.if(!action.isEmphasized) {
237-
$0.buttonStyle(SecondaryButtonStyle())
232+
.buttonStyle(SecondaryButtonStyle())
233+
case .markAsPaid:
234+
Button(action.buttonTitle, action: markBookingAsPaid)
235+
.buttonStyle(PrimaryLoadingButtonStyle(isLoading: markingAsPaid))
238236
}
239237
}
240238
}
@@ -262,6 +260,18 @@ extension BookingDetailsView {
262260
cancellingBooking = false
263261
}
264262
}
263+
264+
func markBookingAsPaid() {
265+
Task { @MainActor in
266+
markingAsPaid = true
267+
do {
268+
try await viewModel.markBookingAsPaid()
269+
} catch {
270+
viewModel.displayMarkingAsPaidErrorNotice(onRetry: markBookingAsPaid)
271+
}
272+
markingAsPaid = false
273+
}
274+
}
265275
}
266276

267277
extension BookingDetailsView {

0 commit comments

Comments
 (0)