Skip to main content

datasynth_generators/industry/retail/
transactions.rs

1//! Retail transaction types.
2
3use 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/// Point of sale transaction types.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub enum PosTransaction {
13    /// Standard sale.
14    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    /// Customer return.
27    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    /// Voided transaction.
40    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    /// Price override.
52    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    /// Employee discount applied.
62    EmployeeDiscount {
63        transaction_id: String,
64        employee_id: String,
65        discount_amount: Decimal,
66        beneficiary_relationship: String,
67        timestamp: NaiveDateTime,
68    },
69    /// Loyalty redemption.
70    LoyaltyRedemption {
71        transaction_id: String,
72        customer_id: String,
73        points_redeemed: u32,
74        value_redeemed: Decimal,
75        timestamp: NaiveDateTime,
76    },
77}
78
79/// Sale item line.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct SaleItem {
82    /// SKU number.
83    pub sku: String,
84    /// Product name.
85    pub product_name: String,
86    /// Quantity sold.
87    pub quantity: u32,
88    /// Unit price.
89    pub unit_price: Decimal,
90    /// Discount amount.
91    pub discount: Decimal,
92    /// Line total.
93    pub line_total: Decimal,
94    /// Department code.
95    pub department: String,
96    /// Category.
97    pub category: String,
98}
99
100/// Return item line.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ReturnItem {
103    /// SKU number.
104    pub sku: String,
105    /// Quantity returned.
106    pub quantity: u32,
107    /// Refund price per unit.
108    pub refund_price: Decimal,
109    /// Return reason.
110    pub reason: String,
111    /// Condition (new, damaged, etc.).
112    pub condition: String,
113    /// Whether item is restockable.
114    pub restockable: bool,
115}
116
117/// Inventory transaction types.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub enum InventoryTransaction {
120    /// Inventory receipt.
121    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    /// Inter-store transfer.
130    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    /// Physical count adjustment.
139    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    /// Shrinkage write-off.
152    ShrinkageWriteOff {
153        writeoff_id: String,
154        store_id: String,
155        category: String,
156        amount: Decimal,
157        reason: ShrinkageReason,
158        date: NaiveDate,
159    },
160    /// Markdown.
161    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/// Receipt item.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ReceiptItem {
176    /// SKU number.
177    pub sku: String,
178    /// Quantity received.
179    pub quantity_received: u32,
180    /// Quantity ordered.
181    pub quantity_ordered: u32,
182    /// Unit cost.
183    pub unit_cost: Decimal,
184}
185
186/// Transfer item.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct TransferItem {
189    /// SKU number.
190    pub sku: String,
191    /// Quantity.
192    pub quantity: u32,
193    /// Unit cost.
194    pub unit_cost: Decimal,
195}
196
197/// Shrinkage reason codes.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
199pub enum ShrinkageReason {
200    /// Employee theft.
201    EmployeeTheft,
202    /// External theft (shoplifting).
203    ExternalTheft,
204    /// Administrative error.
205    AdminError,
206    /// Vendor fraud.
207    VendorFraud,
208    /// Damage/spoilage.
209    Damage,
210    /// Unknown.
211    Unknown,
212}
213
214impl ShrinkageReason {
215    /// Returns the reason code.
216    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/// Union type for all retail transactions.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub enum RetailTransaction {
231    /// Point of sale transaction.
232    Pos(PosTransaction),
233    /// Inventory transaction.
234    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/// Generator for retail transactions.
385#[derive(Debug, Clone)]
386pub struct RetailTransactionGenerator {
387    /// Average transactions per store per day.
388    pub avg_daily_transactions: u32,
389    /// Return rate (0.0-1.0).
390    pub return_rate: f64,
391    /// Void rate (0.0-1.0).
392    pub void_rate: f64,
393    /// Price override rate (0.0-1.0).
394    pub override_rate: f64,
395    /// Shrinkage rate (0.0-1.0).
396    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    /// Returns retail-specific GL accounts.
413    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}