Skip to content

Commit

Permalink
[Woo POS] Extract order controller from aggregate model (#14523)
Browse files Browse the repository at this point in the history
  • Loading branch information
joshheald authored Nov 27, 2024
2 parents d7336a4 + d9aa719 commit 9122a67
Show file tree
Hide file tree
Showing 14 changed files with 525 additions and 216 deletions.
145 changes: 145 additions & 0 deletions WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import Foundation
import Combine
import protocol Yosemite.POSOrderServiceProtocol
import struct Yosemite.Order
import struct Yosemite.POSCartItem
import class WooFoundation.CurrencyFormatter

protocol PointOfSaleOrderControllerProtocol {
var orderStatePublisher: AnyPublisher<PointOfSaleInternalOrderState, Never> { get }

@available(*, deprecated, message: "This property will be removed when possible. Use `orderState.loaded` instead.")
var order: Order? { get }

func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async
func clearOrder()
}

final class PointOfSaleOrderController: PointOfSaleOrderControllerProtocol {
init(orderService: POSOrderServiceProtocol,
currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings)) {
self.orderService = orderService
self.currencyFormatter = currencyFormatter
}

var orderStatePublisher: AnyPublisher<PointOfSaleInternalOrderState, Never> {
$orderState.eraseToAnyPublisher()
}

private let orderService: POSOrderServiceProtocol

private let currencyFormatter: CurrencyFormatter

@Published private var orderState: PointOfSaleInternalOrderState = .idle
private(set) var order: Order? = nil

@MainActor
func syncOrder(for cartProducts: [CartItem],
retryHandler: @escaping () async -> Void) async {
guard !orderState.isSyncing,
CartItem.areOrderAndCartDifferent(order: order, cartItems: cartProducts) else {
return
}

orderState = .syncing
let cartItems = cartProducts.map {
POSCartItem(product: $0.item, quantity: Decimal($0.quantity))
}

do {
let syncedOrder = try await orderService.syncOrder(cart: cartItems, order: order)
self.order = syncedOrder
orderState = .loaded(totals(for: syncedOrder), syncedOrder)
DDLogInfo("🟢 [POS] Synced order: \(syncedOrder)")
} catch {
DDLogError("🔴 [POS] Error syncing order: \(error)")
setOrderStateToError(error, retryHandler: retryHandler)
}
}

private func setOrderStateToError(_ error: Error,
retryHandler: @escaping () async -> Void) {
// Consider removing error or handle specific errors with our own formatting and localization
orderState = .error(.init(message: error.localizedDescription,
handler: {
Task {
await retryHandler()
}
}))
}

func clearOrder() {
order = nil
orderState = .idle
}
}


private extension PointOfSaleOrderController {
func totals(for order: Order) -> PointOfSaleOrderTotals {
let totalsCalculator = OrderTotalsCalculator(for: order,
using: currencyFormatter)
return PointOfSaleOrderTotals(
cartTotal: formattedPrice(totalsCalculator.itemsTotal.stringValue,
currency: order.currency) ?? "",
orderTotal: formattedPrice(order.total, currency: order.currency) ?? "",
taxTotal: formattedPrice(order.totalTax, currency: order.currency) ?? "")
}

func formattedPrice(_ price: String?, currency: String?) -> String? {
guard let price, let currency else {
return nil
}
return currencyFormatter.formatAmount(price, with: currency)
}
}

// This is named to note that it is for use within the AggregateModel and OrderController.
// Conversely, PointOfSaleOrderState is available to the Views, as it doesn't include the Order.
enum PointOfSaleInternalOrderState {
case idle
case syncing
case loaded(PointOfSaleOrderTotals, Order)
case error(PointOfSaleOrderSyncErrorMessageViewModel)

var isSyncing: Bool {
switch self {
case .syncing:
return true
default:
return false
}
}

var externalState: PointOfSaleOrderState {
switch self {
case .idle:
return .idle
case .error(let error):
return .error(error)
case .loaded(let totals, _):
return .loaded(totals)
case .syncing:
return .syncing
}
}
}

extension PointOfSaleInternalOrderState: Equatable {
static func ==(lhs: PointOfSaleInternalOrderState, rhs: PointOfSaleInternalOrderState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle):
return true
case (.error(let lhsError), .error(let rhsError)):
return lhsError.title == rhsError.title &&
lhsError.message == rhsError.message
case (.syncing, .syncing):
return true
case (.loaded(let lhsTotals, let lhsOrder), .loaded(let rhsTotals, let rhsOrder)):
return lhsTotals == rhsTotals &&
lhsOrder == rhsOrder
default:
return false
}
}
}
85 changes: 14 additions & 71 deletions WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import protocol Yosemite.POSItem
import protocol WooFoundation.Analytics
import struct Yosemite.Order
import struct Yosemite.OrderItem
import protocol Yosemite.POSOrderServiceProtocol
import struct Yosemite.POSCartItem
import class WooFoundation.CurrencyFormatter

protocol PointOfSaleAggregateModelProtocol {
var orderStage: PointOfSaleOrderStage { get }
Expand Down Expand Up @@ -54,13 +52,10 @@ class PointOfSaleAggregateModel: ObservableObject, PointOfSaleAggregateModelProt

@Published private(set) var orderState: PointOfSaleOrderState = .idle

private var order: Order? = nil

private let itemsService: PointOfSaleItemsServiceProtocol

private let cardPresentPaymentService: CardPresentPaymentFacade
private let orderService: POSOrderServiceProtocol
private let currencyFormatter: CurrencyFormatter
private let orderController: PointOfSaleOrderControllerProtocol
private let analytics: Analytics

private var startPaymentOnCardReaderConnection: AnyCancellable?
Expand All @@ -70,19 +65,18 @@ class PointOfSaleAggregateModel: ObservableObject, PointOfSaleAggregateModelProt

init(itemsService: PointOfSaleItemsServiceProtocol,
cardPresentPaymentService: CardPresentPaymentFacade,
orderService: POSOrderServiceProtocol,
currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings),
orderController: PointOfSaleOrderControllerProtocol,
analytics: Analytics = ServiceLocator.analytics,
paymentState: PointOfSalePaymentState = .idle) {
self.itemsService = itemsService
self.cardPresentPaymentService = cardPresentPaymentService
self.orderService = orderService
self.currencyFormatter = currencyFormatter
self.orderController = orderController
self.analytics = analytics
self.paymentState = paymentState
publishItemListState()
publishCardReaderConnectionStatus()
publishPaymentMessages()
publishOrderState()
setupReaderReconnectionObservation()
}
}
Expand Down Expand Up @@ -133,7 +127,7 @@ extension PointOfSaleAggregateModel {

func startNewCart() {
removeAllItemsFromCart()
clearOrder()
orderController.clearOrder()
setStateForEditing()
}

Expand Down Expand Up @@ -191,7 +185,7 @@ extension PointOfSaleAggregateModel {

@MainActor
func collectPayment() async {
guard let order else {
guard let order = orderController.order else {
return
// Should this throw?
}
Expand Down Expand Up @@ -355,67 +349,16 @@ extension PointOfSaleAggregateModel {
@MainActor
func checkOut() async {
orderStage = .finalizing

guard CartItem.areOrderAndCartDifferent(order: order, cartItems: cart) else {
await startPaymentWhenCardReaderConnected()
return
}
// calculate totals and sync order if there was a change in the cart
await syncOrder(for: cart)
}

@MainActor
private func syncOrder(for cartProducts: [CartItem]) async {
guard orderState.isSyncing == false else {
return
}
orderState = .syncing
let cart = cartProducts.map {
POSCartItem(product: $0.item, quantity: Decimal($0.quantity))
}

do {
let syncedOrder = try await orderService.syncOrder(cart: cart, order: order)
self.order = syncedOrder
orderState = .loaded(totals(for: syncedOrder))
await startPaymentWhenCardReaderConnected()
DDLogInfo("🟢 [POS] Synced order: \(syncedOrder)")
} catch {
DDLogError("🔴 [POS] Error syncing order: \(error)")

// Consider removing error or handle specific errors with our own formatting and localization
orderState = .error(.init(message: error.localizedDescription, handler: { [weak self] in
Task {
await self?.syncOrder(for: cartProducts)
}
}))
}
}

private func clearOrder() {
order = nil
orderState = .idle
}
}

// MARK: - Price formatters

private extension PointOfSaleAggregateModel {
func totals(for order: Order) -> PointOfSaleOrderTotals {
let totalsCalculator = OrderTotalsCalculator(for: order,
using: currencyFormatter)
return PointOfSaleOrderTotals(
cartTotal: formattedPrice(totalsCalculator.itemsTotal.stringValue,
currency: order.currency) ?? "",
orderTotal: formattedPrice(order.total, currency: order.currency) ?? "",
taxTotal: formattedPrice(order.totalTax, currency: order.currency) ?? "")
await orderController.syncOrder(for: cart, retryHandler: { [weak self] in
await self?.checkOut()
})
await startPaymentWhenCardReaderConnected()
}

func formattedPrice(_ price: String?, currency: String?) -> String? {
guard let price, let currency else {
return nil
}
return currencyFormatter.formatAmount(price, with: currency)
func publishOrderState() {
orderController.orderStatePublisher
.map { $0.externalState }
.assign(to: &$orderState)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ struct PointOfSaleEntryPointView: View {
init(itemProvider: POSItemProvider,
onPointOfSaleModeActiveStateChange: @escaping ((Bool) -> Void),
cardPresentPaymentService: CardPresentPaymentFacade,
orderService: POSOrderServiceProtocol) {
orderController: PointOfSaleOrderControllerProtocol) {
self.onPointOfSaleModeActiveStateChange = onPointOfSaleModeActiveStateChange

let posModel = PointOfSaleAggregateModel(itemsService: PointOfSaleItemsService(itemProvider: itemProvider),
cardPresentPaymentService: cardPresentPaymentService,
orderService: orderService)
let posModel = PointOfSaleAggregateModel(
itemsService: PointOfSaleItemsService(itemProvider: itemProvider),
cardPresentPaymentService: cardPresentPaymentService,
orderController: orderController)

self._posModel = StateObject(wrappedValue: posModel)
}
Expand All @@ -39,6 +40,6 @@ struct PointOfSaleEntryPointView: View {
PointOfSaleEntryPointView(itemProvider: POSItemProviderPreview(),
onPointOfSaleModeActiveStateChange: { _ in },
cardPresentPaymentService: CardPresentPaymentPreviewService(),
orderService: POSOrderPreviewService())
orderController: PointOfSalePreviewOrderController())
}
#endif
2 changes: 1 addition & 1 deletion WooCommerce/Classes/POS/Presentation/TotalsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ private extension View {
let posModel = PointOfSaleAggregateModel(
itemsService: PointOfSaleItemsPreviewService(),
cardPresentPaymentService: CardPresentPaymentPreviewService(),
orderService: POSOrderPreviewService())
orderController: PointOfSalePreviewOrderController())
TotalsView()
.environmentObject(posModel)
}
Expand Down
14 changes: 0 additions & 14 deletions WooCommerce/Classes/POS/Utils/POSOrderPreviewService.swift

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#if DEBUG
import enum Yosemite.OrderFactory
import struct Yosemite.Order
import Combine

class PointOfSalePreviewOrderController: PointOfSaleOrderControllerProtocol {
var orderStatePublisher: AnyPublisher<PointOfSaleInternalOrderState, Never> = Just(
.loaded(
.init(cartTotal: "$10.50",
orderTotal: "$12.00",
taxTotal: "$1.50"),
OrderFactory.emptyNewOrder
)
).eraseToAnyPublisher()

var order: Yosemite.Order?

func syncOrder(for cartProducts: [CartItem],
retryHandler: @escaping () async -> Void) async { }

func clearOrder() { }
}
#endif
2 changes: 1 addition & 1 deletion WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ private extension HubMenu {
viewModel.updateDefaultConfigurationForPointOfSale(isEnabled)
},
cardPresentPaymentService: cardPresentPaymentService,
orderService: orderService)
orderController: PointOfSaleOrderController(orderService: orderService))
} else {
// TODO: When we have a singleton for the card payment service, this should not be required.
Text("Error creating card payment service")
Expand Down
Loading

0 comments on commit 9122a67

Please sign in to comment.