1use chrono::{NaiveDate, NaiveDateTime};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use super::super::common::{IndustryGlAccount, IndustryJournalLine, IndustryTransaction};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub enum PosTransaction {
13 Sale {
15 transaction_id: String,
16 store_id: String,
17 register_id: String,
18 cashier_id: String,
19 items: Vec<SaleItem>,
20 subtotal: Decimal,
21 tax: Decimal,
22 total: Decimal,
23 payment_method: String,
24 timestamp: NaiveDateTime,
25 },
26 Return {
28 transaction_id: String,
29 original_transaction_id: String,
30 store_id: String,
31 register_id: String,
32 cashier_id: String,
33 items: Vec<ReturnItem>,
34 refund_amount: Decimal,
35 refund_method: String,
36 reason_code: String,
37 timestamp: NaiveDateTime,
38 },
39 Void {
41 transaction_id: String,
42 voided_transaction_id: String,
43 store_id: String,
44 register_id: String,
45 cashier_id: String,
46 supervisor_id: Option<String>,
47 void_reason: String,
48 original_amount: Decimal,
49 timestamp: NaiveDateTime,
50 },
51 PriceOverride {
53 transaction_id: String,
54 item_sku: String,
55 original_price: Decimal,
56 override_price: Decimal,
57 reason_code: String,
58 approver_id: Option<String>,
59 timestamp: NaiveDateTime,
60 },
61 EmployeeDiscount {
63 transaction_id: String,
64 employee_id: String,
65 discount_amount: Decimal,
66 beneficiary_relationship: String,
67 timestamp: NaiveDateTime,
68 },
69 LoyaltyRedemption {
71 transaction_id: String,
72 customer_id: String,
73 points_redeemed: u32,
74 value_redeemed: Decimal,
75 timestamp: NaiveDateTime,
76 },
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct SaleItem {
82 pub sku: String,
84 pub product_name: String,
86 pub quantity: u32,
88 pub unit_price: Decimal,
90 pub discount: Decimal,
92 pub line_total: Decimal,
94 pub department: String,
96 pub category: String,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ReturnItem {
103 pub sku: String,
105 pub quantity: u32,
107 pub refund_price: Decimal,
109 pub reason: String,
111 pub condition: String,
113 pub restockable: bool,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub enum InventoryTransaction {
120 Receipt {
122 receipt_id: String,
123 po_id: String,
124 store_id: String,
125 items: Vec<ReceiptItem>,
126 received_by: String,
127 date: NaiveDate,
128 },
129 Transfer {
131 transfer_id: String,
132 from_store: String,
133 to_store: String,
134 items: Vec<TransferItem>,
135 status: String,
136 date: NaiveDate,
137 },
138 CountAdjustment {
140 adjustment_id: String,
141 store_id: String,
142 sku: String,
143 system_quantity: i32,
144 physical_quantity: i32,
145 variance: i32,
146 unit_cost: Decimal,
147 reason_code: String,
148 approved_by: Option<String>,
149 date: NaiveDate,
150 },
151 ShrinkageWriteOff {
153 writeoff_id: String,
154 store_id: String,
155 category: String,
156 amount: Decimal,
157 reason: ShrinkageReason,
158 date: NaiveDate,
159 },
160 Markdown {
162 markdown_id: String,
163 store_id: String,
164 sku: String,
165 original_price: Decimal,
166 markdown_price: Decimal,
167 quantity_affected: u32,
168 reason: String,
169 date: NaiveDate,
170 },
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ReceiptItem {
176 pub sku: String,
178 pub quantity_received: u32,
180 pub quantity_ordered: u32,
182 pub unit_cost: Decimal,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct TransferItem {
189 pub sku: String,
191 pub quantity: u32,
193 pub unit_cost: Decimal,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
199pub enum ShrinkageReason {
200 EmployeeTheft,
202 ExternalTheft,
204 AdminError,
206 VendorFraud,
208 Damage,
210 Unknown,
212}
213
214impl ShrinkageReason {
215 pub fn code(&self) -> &'static str {
217 match self {
218 ShrinkageReason::EmployeeTheft => "EMP",
219 ShrinkageReason::ExternalTheft => "EXT",
220 ShrinkageReason::AdminError => "ADM",
221 ShrinkageReason::VendorFraud => "VND",
222 ShrinkageReason::Damage => "DMG",
223 ShrinkageReason::Unknown => "UNK",
224 }
225 }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub enum RetailTransaction {
231 Pos(PosTransaction),
233 Inventory(InventoryTransaction),
235}
236
237impl IndustryTransaction for RetailTransaction {
238 fn transaction_type(&self) -> &str {
239 match self {
240 RetailTransaction::Pos(pos) => match pos {
241 PosTransaction::Sale { .. } => "pos_sale",
242 PosTransaction::Return { .. } => "pos_return",
243 PosTransaction::Void { .. } => "pos_void",
244 PosTransaction::PriceOverride { .. } => "price_override",
245 PosTransaction::EmployeeDiscount { .. } => "employee_discount",
246 PosTransaction::LoyaltyRedemption { .. } => "loyalty_redemption",
247 },
248 RetailTransaction::Inventory(inv) => match inv {
249 InventoryTransaction::Receipt { .. } => "inventory_receipt",
250 InventoryTransaction::Transfer { .. } => "inventory_transfer",
251 InventoryTransaction::CountAdjustment { .. } => "count_adjustment",
252 InventoryTransaction::ShrinkageWriteOff { .. } => "shrinkage_writeoff",
253 InventoryTransaction::Markdown { .. } => "markdown",
254 },
255 }
256 }
257
258 fn date(&self) -> NaiveDate {
259 match self {
260 RetailTransaction::Pos(pos) => match pos {
261 PosTransaction::Sale { timestamp, .. }
262 | PosTransaction::Return { timestamp, .. }
263 | PosTransaction::Void { timestamp, .. }
264 | PosTransaction::PriceOverride { timestamp, .. }
265 | PosTransaction::EmployeeDiscount { timestamp, .. }
266 | PosTransaction::LoyaltyRedemption { timestamp, .. } => timestamp.date(),
267 },
268 RetailTransaction::Inventory(inv) => match inv {
269 InventoryTransaction::Receipt { date, .. }
270 | InventoryTransaction::Transfer { date, .. }
271 | InventoryTransaction::CountAdjustment { date, .. }
272 | InventoryTransaction::ShrinkageWriteOff { date, .. }
273 | InventoryTransaction::Markdown { date, .. } => *date,
274 },
275 }
276 }
277
278 fn amount(&self) -> Option<Decimal> {
279 match self {
280 RetailTransaction::Pos(pos) => match pos {
281 PosTransaction::Sale { total, .. } => Some(*total),
282 PosTransaction::Return { refund_amount, .. } => Some(*refund_amount),
283 PosTransaction::Void {
284 original_amount, ..
285 } => Some(*original_amount),
286 PosTransaction::PriceOverride {
287 original_price,
288 override_price,
289 ..
290 } => Some(*original_price - *override_price),
291 PosTransaction::EmployeeDiscount {
292 discount_amount, ..
293 } => Some(*discount_amount),
294 PosTransaction::LoyaltyRedemption { value_redeemed, .. } => Some(*value_redeemed),
295 },
296 RetailTransaction::Inventory(inv) => match inv {
297 InventoryTransaction::ShrinkageWriteOff { amount, .. } => Some(*amount),
298 InventoryTransaction::CountAdjustment {
299 variance,
300 unit_cost,
301 ..
302 } => Some(Decimal::from(*variance) * *unit_cost),
303 _ => None,
304 },
305 }
306 }
307
308 fn accounts(&self) -> Vec<String> {
309 match self {
310 RetailTransaction::Pos(pos) => match pos {
311 PosTransaction::Sale { .. } => {
312 vec!["1100".to_string(), "4100".to_string(), "2300".to_string()]
313 }
314 PosTransaction::Return { .. } => {
315 vec!["4200".to_string(), "1100".to_string()]
316 }
317 _ => Vec::new(),
318 },
319 RetailTransaction::Inventory(inv) => match inv {
320 InventoryTransaction::ShrinkageWriteOff { .. } => {
321 vec!["5300".to_string(), "1400".to_string()]
322 }
323 InventoryTransaction::CountAdjustment { .. } => {
324 vec!["5310".to_string(), "1400".to_string()]
325 }
326 _ => Vec::new(),
327 },
328 }
329 }
330
331 fn to_journal_lines(&self) -> Vec<IndustryJournalLine> {
332 match self {
333 RetailTransaction::Pos(PosTransaction::Sale {
334 total,
335 tax,
336 store_id,
337 ..
338 }) => {
339 let pretax = *total - *tax;
340 vec![
341 IndustryJournalLine::debit("1100", *total, "Cash/AR from sales")
342 .with_dimension("store", store_id),
343 IndustryJournalLine::credit("4100", pretax, "Sales Revenue"),
344 IndustryJournalLine::credit("2300", *tax, "Sales Tax Payable"),
345 ]
346 }
347 RetailTransaction::Pos(PosTransaction::Return { refund_amount, .. }) => {
348 vec![
349 IndustryJournalLine::debit("4200", *refund_amount, "Sales Returns"),
350 IndustryJournalLine::credit("1100", *refund_amount, "Cash/AR refund"),
351 ]
352 }
353 RetailTransaction::Inventory(InventoryTransaction::ShrinkageWriteOff {
354 amount,
355 reason,
356 store_id,
357 ..
358 }) => {
359 vec![
360 IndustryJournalLine::debit(
361 "5300",
362 *amount,
363 format!("Shrinkage - {:?}", reason),
364 )
365 .with_dimension("store", store_id),
366 IndustryJournalLine::credit("1400", *amount, "Inventory reduction"),
367 ]
368 }
369 _ => Vec::new(),
370 }
371 }
372
373 fn metadata(&self) -> HashMap<String, String> {
374 let mut meta = HashMap::new();
375 meta.insert("industry".to_string(), "retail".to_string());
376 meta.insert(
377 "transaction_type".to_string(),
378 self.transaction_type().to_string(),
379 );
380 meta
381 }
382}
383
384#[derive(Debug, Clone)]
386pub struct RetailTransactionGenerator {
387 pub avg_daily_transactions: u32,
389 pub return_rate: f64,
391 pub void_rate: f64,
393 pub override_rate: f64,
395 pub shrinkage_rate: f64,
397}
398
399impl Default for RetailTransactionGenerator {
400 fn default() -> Self {
401 Self {
402 avg_daily_transactions: 200,
403 return_rate: 0.08,
404 void_rate: 0.02,
405 override_rate: 0.05,
406 shrinkage_rate: 0.015,
407 }
408 }
409}
410
411impl RetailTransactionGenerator {
412 pub fn gl_accounts() -> Vec<IndustryGlAccount> {
414 vec![
415 IndustryGlAccount::new("1100", "Cash and Cash Equivalents", "Asset", "Cash")
416 .into_control(),
417 IndustryGlAccount::new("1400", "Merchandise Inventory", "Asset", "Inventory")
418 .into_control(),
419 IndustryGlAccount::new("2300", "Sales Tax Payable", "Liability", "Tax")
420 .with_normal_balance("Credit"),
421 IndustryGlAccount::new("4100", "Sales Revenue", "Revenue", "Sales")
422 .with_normal_balance("Credit"),
423 IndustryGlAccount::new("4200", "Sales Returns and Allowances", "Revenue", "Sales"),
424 IndustryGlAccount::new("4300", "Sales Discounts", "Revenue", "Sales"),
425 IndustryGlAccount::new("5100", "Cost of Goods Sold", "Expense", "COGS"),
426 IndustryGlAccount::new("5200", "Freight In", "Expense", "COGS"),
427 IndustryGlAccount::new("5300", "Inventory Shrinkage", "Expense", "Shrinkage"),
428 IndustryGlAccount::new("5310", "Inventory Adjustments", "Expense", "Shrinkage"),
429 IndustryGlAccount::new("5400", "Markdown Expense", "Expense", "Markdown"),
430 IndustryGlAccount::new("5500", "Employee Discount Expense", "Expense", "Discount"),
431 IndustryGlAccount::new("5600", "Loyalty Program Expense", "Expense", "Loyalty"),
432 ]
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn test_pos_sale() {
442 let timestamp = NaiveDate::from_ymd_opt(2024, 1, 15)
443 .unwrap()
444 .and_hms_opt(14, 30, 0)
445 .unwrap();
446
447 let tx = RetailTransaction::Pos(PosTransaction::Sale {
448 transaction_id: "TRX001".to_string(),
449 store_id: "S001".to_string(),
450 register_id: "R01".to_string(),
451 cashier_id: "C001".to_string(),
452 items: vec![SaleItem {
453 sku: "SKU001".to_string(),
454 product_name: "Widget".to_string(),
455 quantity: 2,
456 unit_price: Decimal::new(1999, 2),
457 discount: Decimal::ZERO,
458 line_total: Decimal::new(3998, 2),
459 department: "D001".to_string(),
460 category: "Widgets".to_string(),
461 }],
462 subtotal: Decimal::new(3998, 2),
463 tax: Decimal::new(320, 2),
464 total: Decimal::new(4318, 2),
465 payment_method: "credit_card".to_string(),
466 timestamp,
467 });
468
469 assert_eq!(tx.transaction_type(), "pos_sale");
470 assert_eq!(tx.amount(), Some(Decimal::new(4318, 2)));
471 assert_eq!(tx.accounts().len(), 3);
472 }
473
474 #[test]
475 fn test_shrinkage_writeoff() {
476 let tx = RetailTransaction::Inventory(InventoryTransaction::ShrinkageWriteOff {
477 writeoff_id: "WO001".to_string(),
478 store_id: "S001".to_string(),
479 category: "Electronics".to_string(),
480 amount: Decimal::new(500, 0),
481 reason: ShrinkageReason::ExternalTheft,
482 date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
483 });
484
485 assert_eq!(tx.transaction_type(), "shrinkage_writeoff");
486 assert_eq!(tx.amount(), Some(Decimal::new(500, 0)));
487
488 let lines = tx.to_journal_lines();
489 assert_eq!(lines.len(), 2);
490 assert_eq!(lines[0].debit, Decimal::new(500, 0));
491 }
492
493 #[test]
494 fn test_gl_accounts() {
495 let accounts = RetailTransactionGenerator::gl_accounts();
496 assert!(accounts.len() >= 10);
497
498 let inventory = accounts.iter().find(|a| a.account_number == "1400");
499 assert!(inventory.is_some());
500 }
501}