domain/retail/pos.rs
1//! POS models for attaching retail sales to staff transactions or reservation checkout while preserving approval gates.
2
3use bon::Builder;
4use serde::{Deserialize, Deserializer, Serialize};
5
6use crate::{entities, policy};
7
8use super::product::LocationOffering;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
11/// Positive sale quantity used to ensure retail checkout never drafts zero-unit line items.
12pub struct Quantity(u32);
13
14impl Quantity {
15 /// Accepts a positive sale quantity so POS drafts never create zero-unit retail line items.
16 pub const fn try_new(value: u32) -> std::result::Result<Self, QuantityError> {
17 if value == 0 {
18 return Err(QuantityError::Zero);
19 }
20 Ok(Self(value))
21 }
22
23 /// Returns the quantity for checkout mapping, inventory checks, and audit records.
24 pub const fn get(self) -> u32 {
25 self.0
26 }
27}
28
29impl<'de> Deserialize<'de> for Quantity {
30 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
31 where
32 D: Deserializer<'de>,
33 {
34 Self::try_new(u32::deserialize(deserializer)?).map_err(serde::de::Error::custom)
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
39/// Quantity validation errors that prevent unusable POS sale drafts.
40pub enum QuantityError {
41 #[error("retail sale quantity requires at least one unit")]
42 /// Rejects zero where the pet-resort workflow requires a positive quantity.
43 Zero,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47/// POS policy deciding which retail sources are allowed and which price actions need manager review.
48pub enum Policy {
49 /// Allows staff to draft an in-person retail sale that remains separate from reservation checkout.
50 StandaloneSale,
51 /// Allows a sale to be proposed during reservation checkout but still requires customer-message approval.
52 IntegratedWithReservationCheckout,
53 /// Forces manager approval before any comp, refund, or discount reaches POS workflow.
54 ManagerOnlyComp,
55}
56
57impl Policy {
58 /// Evaluates sale eligibility from offering status, inventory, source, and price-exception policy.
59 pub fn evaluate(&self, request: &Request) -> Decision {
60 if !request.offering.can_be_sold_to_customer() {
61 return Decision::Denied {
62 reason: DenialReason::OfferingNotSellable,
63 };
64 }
65 if !request.offering.has_available_sale_units(request.quantity) {
66 return Decision::Denied {
67 reason: DenialReason::InventoryUnavailable,
68 };
69 }
70 if request.price_adjustment.requires_manager_approval()
71 || matches!(self, Self::ManagerOnlyComp)
72 {
73 return Decision::ReviewRequired {
74 reason: ReviewReason::PriceException,
75 gate: policy::ReviewGate::ManagerApproval,
76 };
77 }
78 match (self, &request.source) {
79 (Self::StandaloneSale, Source::StandaloneStaffSale { .. }) => Decision::DraftAllowed,
80 (Self::IntegratedWithReservationCheckout, Source::ReservationCheckout { .. }) => {
81 Decision::ReviewRequired {
82 reason: ReviewReason::ReservationCheckoutAttachment,
83 gate: policy::ReviewGate::CustomerMessageApproval,
84 }
85 }
86 _ => Decision::Denied {
87 reason: DenialReason::SourceNotAllowed,
88 },
89 }
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
94/// Retail sale request combining offering, quantity, source, and price-adjustment context.
95pub struct Request {
96 /// Location offering being checked for sellability, usage policy, and available units.
97 pub offering: LocationOffering,
98 /// Positive unit count staff want to sell or attach to checkout.
99 pub quantity: Quantity,
100 /// Sale origin used to block unsupported POS or reservation mutations.
101 pub source: Source,
102 /// Discount, comp, refund, or reversal context that may require manager approval.
103 pub price_adjustment: PriceAdjustment,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107/// Source of the retail sale attempt, used to prevent unsupported POS or reservation mutations.
108pub enum Source {
109 /// Staff-originated counter sale that may draft when policy and inventory allow it.
110 StandaloneStaffSale {
111 /// Staff member accountable for the standalone sale draft.
112 staff_id: entities::StaffId,
113 },
114 /// Reservation checkout context that can propose an attachment but cannot send customer copy without approval.
115 ReservationCheckout {
116 /// Reservation receiving a proposed retail attachment after customer-message approval.
117 reservation_id: entities::reservation::Id,
118 },
119 /// Imported POS reconciliation source that is recorded for review instead of mutating checkout from domain code.
120 ExternalPosReconciliation,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124/// Price adjustment or comp that triggers manager review before checkout mutation.
125pub enum PriceAdjustment {
126 /// No discount, comp, refund, or reversal is requested.
127 None,
128 /// Approval reason shown to staff or managers before checkout work proceeds.
129 PolicyDiscount {
130 /// Manager-readable reason for the discount, comp, refund, or reversal request.
131 reason: PriceExceptionReason,
132 },
133 /// Approval reason shown to staff or managers before checkout work proceeds.
134 ManagerComp {
135 /// Manager-readable reason for the discount, comp, refund, or reversal request.
136 reason: PriceExceptionReason,
137 },
138 /// Approval reason shown to staff or managers before checkout work proceeds.
139 RefundOrReversal {
140 /// Manager-readable reason for the discount, comp, refund, or reversal request.
141 reason: PriceExceptionReason,
142 },
143}
144
145impl PriceAdjustment {
146 /// Reports whether this price action must stop for manager approval before checkout changes.
147 pub const fn requires_manager_approval(self) -> bool {
148 !matches!(self, Self::None)
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153/// Price-exception reasons that explain why a manager must approve the checkout change.
154pub enum PriceExceptionReason {
155 /// Discount or comp requested to recover from a customer complaint, requiring manager review.
156 ComplaintRecovery,
157 /// Courtesy adjustment requested by staff, requiring manager review before POS action.
158 StaffCourtesy,
159 /// Refund or reversal correction that must be approved before money movement.
160 RefundCorrection,
161 /// Manager override reason documenting why an exception may proceed after approval.
162 ManagerOverride,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166/// POS decision describing whether a sale draft is allowed, needs review, or is denied.
167pub enum Decision {
168 /// Sale may be drafted internally because product, inventory, source, and price policy all passed.
169 DraftAllowed,
170 /// Sale must pause for the named approval gate before POS, reservation, payment, refund, or discount action.
171 ReviewRequired {
172 /// Approval reason shown to staff or managers before checkout work proceeds.
173 reason: ReviewReason,
174 /// Approval gate that must be satisfied before the retail workflow can proceed.
175 gate: policy::ReviewGate,
176 },
177 /// Sale is blocked before checkout because product, inventory, or source policy failed.
178 Denied {
179 /// Denial reason explaining why checkout work must not proceed.
180 reason: DenialReason,
181 },
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185/// Reason a POS draft must be reviewed before checkout action.
186pub enum ReviewReason {
187 /// Price adjustment requires manager approval before any discount, comp, refund, or reversal.
188 PriceException,
189 /// Reservation attachment requires customer-message approval before staff can proceed.
190 ReservationCheckoutAttachment,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
194/// Reason a retail sale is denied before reaching checkout.
195pub enum DenialReason {
196 /// Product is inactive, discontinued, or not customer-sellable at this location.
197 OfferingNotSellable,
198 /// Available units cannot satisfy the requested quantity, so checkout must not promise the item.
199 InventoryUnavailable,
200 /// Sale origin is not allowed by this POS policy, preventing unsupported POS or reservation writes.
201 SourceNotAllowed,
202}