Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 149
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Event
0.00% covered (danger)
0.00%
0 / 149
0.00% covered (danger)
0.00%
0 / 10
4032
0.00% covered (danger)
0.00%
0 / 1
 isActive
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 isInRedemptionPeriod
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
56
 calculateReward
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 calculateTieredReward
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 toSettingsArray
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 toArray
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
2
 fromRow
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
72
 parseDateTime
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 parseJson
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 validate
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
306
1<?php
2
3namespace BuyerKiosk\ComebackCash\Models;
4
5use DateTime;
6use DateTimeZone;
7
8/**
9 * Event - Comeback Cash event configuration model
10 *
11 * Represents a Comeback Cash promotional event that defines when and how
12 * customers can earn and redeem cash rewards. Events can be configured for
13 * either buy-side (customer selling TO store) or sales-side (customer buying
14 * FROM store) transactions.
15 *
16 * This is a SEPARATE system from the existing Loyalty system (per ADR-1).
17 *
18 * @package BuyerKiosk\ComebackCash\Models
19 */
20class Event
21{
22    // Status constants
23    public const STATUS_DRAFT = 'draft';
24    public const STATUS_SCHEDULED = 'scheduled';
25    public const STATUS_ACTIVE = 'active';
26    public const STATUS_ENDED = 'ended';
27    public const STATUS_CANCELLED = 'cancelled';
28
29    // Side constants
30    public const SIDE_BUY = 'buy';
31    public const SIDE_SALES = 'sales';
32
33    // Earning type constants
34    public const EARNING_TIERED = 'tiered';
35    public const EARNING_FLAT = 'flat';
36    public const EARNING_PERCENTAGE = 'percentage';
37
38    // Refund policy constants
39    public const REFUND_FORFEIT = 'forfeit';
40    public const REFUND_REINSTATE = 'reinstate';
41
42    /**
43     * @var int|null Event ID
44     */
45    public ?int $id = null;
46
47    /**
48     * @var string Event name for display
49     */
50    public string $name = '';
51
52    /**
53     * @var string Transaction side: 'buy' (selling TO store) or 'sales' (buying FROM store)
54     */
55    public string $side = self::SIDE_BUY;
56
57    /**
58     * @var string Event status: 'draft', 'scheduled', 'active', 'ended', 'cancelled'
59     */
60    public string $status = self::STATUS_DRAFT;
61
62    /**
63     * @var DateTime|null When event becomes active (null = manual start)
64     */
65    public ?DateTime $startDate = null;
66
67    /**
68     * @var DateTime|null When event ends (null = manual end)
69     */
70    public ?DateTime $endDate = null;
71
72    /**
73     * @var string Earning calculation type: 'tiered', 'flat', 'percentage'
74     */
75    public string $earningType = self::EARNING_FLAT;
76
77    /**
78     * @var array|null Tiered earning configuration (sales-side only)
79     *                 Format: [{"min": 50, "max": 99.99, "reward": 10}, ...]
80     */
81    public ?array $earningTiers = null;
82
83    /**
84     * @var float|null Flat reward amount - REQUIRED for buy-side, optional for sales-side
85     */
86    public ?float $earningFlatAmount = null;
87
88    /**
89     * @var float|null Percentage of transaction for reward (sales-side only)
90     */
91    public ?float $earningPercentage = null;
92
93    /**
94     * @var float|null Minimum transaction amount required to earn a coupon (pre-tax)
95     */
96    public ?float $minPurchaseToEarn = null;
97
98    /**
99     * @var float|null Minimum purchase amount required to redeem (pre-tax)
100     */
101    public ?float $redemptionMinPurchase = null;
102
103    /**
104     * @var DateTime|null When redemption period starts
105     */
106    public ?DateTime $redemptionStartDate = null;
107
108    /**
109     * @var DateTime|null When redemption period ends
110     */
111    public ?DateTime $redemptionEndDate = null;
112
113    /**
114     * @var int|null Days coupon is valid from issue date
115     */
116    public ?int $redemptionDaysValid = null;
117
118    /**
119     * @var bool Sales-side only: Allow earning new coupon while redeeming
120     */
121    public bool $allowDoubleUp = false;
122
123    /**
124     * @var string Refund handling: 'forfeit' or 'reinstate'
125     */
126    public string $refundPolicy = self::REFUND_FORFEIT;
127
128    /**
129     * @var bool Send SMS notifications for this event
130     */
131    public bool $smsEnabled = true;
132
133    /**
134     * @var int|null User ID who created the event
135     */
136    public ?int $createdBy = null;
137
138    /**
139     * @var string|null Created timestamp
140     */
141    public ?string $createdAt = null;
142
143    /**
144     * @var string|null Updated timestamp
145     */
146    public ?string $updatedAt = null;
147
148    /**
149     * Check if the event is currently active
150     *
151     * An event is active when:
152     * 1. Status is 'active'
153     * 2. Current time is within the start/end date range (if dates are set)
154     *
155     * @param DateTime|null $now Optional current time for testing
156     * @return bool True if event is active
157     */
158    public function isActive(?DateTime $now = null): bool
159    {
160        if ($this->status !== self::STATUS_ACTIVE) {
161            return false;
162        }
163
164        $now = $now ?? new DateTime();
165
166        // Check start date if set
167        if ($this->startDate !== null && $now < $this->startDate) {
168            return false;
169        }
170
171        // Check end date if set
172        if ($this->endDate !== null && $now > $this->endDate) {
173            return false;
174        }
175
176        return true;
177    }
178
179    /**
180     * Check if the current time is within the redemption period
181     *
182     * @param DateTime|null $now Optional current time for testing
183     * @return bool True if in redemption period
184     */
185    public function isInRedemptionPeriod(?DateTime $now = null): bool
186    {
187        $now = $now ?? new DateTime();
188
189        // If no redemption dates set, always in redemption period (while event is active)
190        if ($this->redemptionStartDate === null && $this->redemptionEndDate === null) {
191            return $this->isActive($now);
192        }
193
194        // Check redemption start date if set
195        if ($this->redemptionStartDate !== null && $now < $this->redemptionStartDate) {
196            return false;
197        }
198
199        // Check redemption end date if set
200        if ($this->redemptionEndDate !== null && $now > $this->redemptionEndDate) {
201            return false;
202        }
203
204        return true;
205    }
206
207    /**
208     * Calculate the reward amount for a given transaction
209     *
210     * BUY-SIDE: Always earns flat amount - any completed buy transaction qualifies
211     *           regardless of payout amount. No thresholds apply.
212     *
213     * SALES-SIDE: Apply threshold-based earning rules based on earningType:
214     *   - 'tiered': Find matching tier based on transaction amount
215     *   - 'flat': Return flat amount (no threshold requirement for flat)
216     *   - 'percentage': Return percentage of transaction amount
217     *
218     * @param float $transactionAmount The transaction amount to calculate reward for
219     * @return float|null The reward amount, or null if no reward applies
220     */
221    public function calculateReward(float $transactionAmount): ?float
222    {
223        // Buy-side: Always earns flat amount, no thresholds
224        // Any completed buy transaction qualifies regardless of payout amount
225        if ($this->side === self::SIDE_BUY) {
226            return $this->earningFlatAmount;
227        }
228
229        // Sales-side: Apply threshold-based earning rules
230        switch ($this->earningType) {
231            case self::EARNING_TIERED:
232                return $this->calculateTieredReward($transactionAmount);
233
234            case self::EARNING_FLAT:
235                // Flat earning on sales-side - return flat amount
236                // No minimum threshold for flat type
237                return $this->earningFlatAmount;
238
239            case self::EARNING_PERCENTAGE:
240                if ($this->earningPercentage === null) {
241                    return null;
242                }
243                // Return percentage of transaction amount
244                return round($transactionAmount * ($this->earningPercentage / 100), 2);
245
246            default:
247                return null;
248        }
249    }
250
251    /**
252     * Calculate tiered reward based on transaction amount
253     *
254     * @param float $transactionAmount The transaction amount
255     * @return float|null The tier reward amount, or null if no tier matches
256     */
257    private function calculateTieredReward(float $transactionAmount): ?float
258    {
259        if ($this->earningTiers === null || empty($this->earningTiers)) {
260            return null;
261        }
262
263        // Find matching tier based on transaction amount
264        foreach ($this->earningTiers as $tier) {
265            $min = $tier['min'] ?? 0;
266            $max = $tier['max'] ?? PHP_FLOAT_MAX;
267            $reward = $tier['reward'] ?? null;
268
269            if ($transactionAmount >= $min && $transactionAmount <= $max && $reward !== null) {
270                return (float) $reward;
271            }
272        }
273
274        return null;
275    }
276
277    /**
278     * Convert event to settings array for POS API response
279     *
280     * This format is used by the POS system to display event configuration
281     * and determine reward calculations.
282     *
283     * @return array Associative array for POS API
284     */
285    public function toSettingsArray(): array
286    {
287        return [
288            'id' => $this->id,
289            'name' => $this->name,
290            'side' => $this->side,
291            'status' => $this->status,
292            'isActive' => $this->isActive(),
293            'earningType' => $this->earningType,
294            'earningTiers' => $this->earningTiers,
295            'earningFlatAmount' => $this->earningFlatAmount,
296            'earningPercentage' => $this->earningPercentage,
297            'minPurchaseToEarn' => $this->minPurchaseToEarn,
298            'redemptionMinPurchase' => $this->redemptionMinPurchase,
299            'redemptionDaysValid' => $this->redemptionDaysValid,
300            'allowDoubleUp' => $this->allowDoubleUp,
301            'smsEnabled' => $this->smsEnabled,
302            'startDate' => $this->startDate?->format('Y-m-d H:i:s'),
303            'endDate' => $this->endDate?->format('Y-m-d H:i:s'),
304            'redemptionStartDate' => $this->redemptionStartDate?->format('Y-m-d H:i:s'),
305            'redemptionEndDate' => $this->redemptionEndDate?->format('Y-m-d H:i:s'),
306        ];
307    }
308
309    /**
310     * Convert event to array for serialization
311     *
312     * @return array Associative array representation
313     */
314    public function toArray(): array
315    {
316        return [
317            'id' => $this->id,
318            'name' => $this->name,
319            'side' => $this->side,
320            'status' => $this->status,
321            'startDate' => $this->startDate?->format('Y-m-d H:i:s'),
322            'endDate' => $this->endDate?->format('Y-m-d H:i:s'),
323            'earningType' => $this->earningType,
324            'earningTiers' => $this->earningTiers,
325            'earningFlatAmount' => $this->earningFlatAmount,
326            'earningPercentage' => $this->earningPercentage,
327            'minPurchaseToEarn' => $this->minPurchaseToEarn,
328            'redemptionMinPurchase' => $this->redemptionMinPurchase,
329            'redemptionStartDate' => $this->redemptionStartDate?->format('Y-m-d H:i:s'),
330            'redemptionEndDate' => $this->redemptionEndDate?->format('Y-m-d H:i:s'),
331            'redemptionDaysValid' => $this->redemptionDaysValid,
332            'allowDoubleUp' => $this->allowDoubleUp,
333            'refundPolicy' => $this->refundPolicy,
334            'smsEnabled' => $this->smsEnabled,
335            'createdBy' => $this->createdBy,
336            'createdAt' => $this->createdAt,
337            'updatedAt' => $this->updatedAt,
338        ];
339    }
340
341    /**
342     * Create an Event object from a database row
343     *
344     * Maps snake_case database columns to camelCase properties.
345     *
346     * @param array $row Database row from ccEvents table
347     * @return self Hydrated Event instance
348     */
349    public static function fromRow(array $row): self
350    {
351        $event = new self();
352
353        $event->id = isset($row['id']) ? (int) $row['id'] : null;
354        $event->name = $row['name'] ?? '';
355        $event->side = $row['side'] ?? self::SIDE_BUY;
356        $event->status = $row['status'] ?? self::STATUS_DRAFT;
357
358        // Parse datetime fields
359        $event->startDate = self::parseDateTime($row['start_date'] ?? null);
360        $event->endDate = self::parseDateTime($row['end_date'] ?? null);
361
362        // Earning configuration
363        $event->earningType = $row['earning_type'] ?? self::EARNING_FLAT;
364        $event->earningTiers = self::parseJson($row['earning_tiers'] ?? null);
365        $event->earningFlatAmount = isset($row['earning_flat_amount'])
366            ? (float) $row['earning_flat_amount']
367            : null;
368        $event->earningPercentage = isset($row['earning_percentage'])
369            ? (float) $row['earning_percentage']
370            : null;
371        $event->minPurchaseToEarn = isset($row['min_purchase_to_earn'])
372            ? (float) $row['min_purchase_to_earn']
373            : null;
374
375        // Redemption configuration
376        $event->redemptionMinPurchase = isset($row['redemption_min_purchase'])
377            ? (float) $row['redemption_min_purchase']
378            : null;
379        $event->redemptionStartDate = self::parseDateTime($row['redemption_start_date'] ?? null);
380        $event->redemptionEndDate = self::parseDateTime($row['redemption_end_date'] ?? null);
381        $event->redemptionDaysValid = isset($row['redemption_days_valid'])
382            ? (int) $row['redemption_days_valid']
383            : null;
384
385        // Flags
386        $event->allowDoubleUp = (bool) ($row['allow_double_up'] ?? false);
387        $event->refundPolicy = $row['refund_policy'] ?? self::REFUND_FORFEIT;
388        $event->smsEnabled = (bool) ($row['sms_enabled'] ?? true);
389
390        // Audit fields
391        $event->createdBy = isset($row['created_by']) ? (int) $row['created_by'] : null;
392        $event->createdAt = $row['created_at'] ?? null;
393        $event->updatedAt = $row['updated_at'] ?? null;
394
395        return $event;
396    }
397
398    /**
399     * Parse a datetime string into a DateTime object
400     *
401     * @param string|null $value Datetime string or null
402     * @return DateTime|null Parsed DateTime or null
403     */
404    private static function parseDateTime(?string $value): ?DateTime
405    {
406        if ($value === null || $value === '' || $value === '0000-00-00 00:00:00') {
407            return null;
408        }
409
410        try {
411            return new DateTime($value);
412        } catch (\Exception $e) {
413            return null;
414        }
415    }
416
417    /**
418     * Parse a JSON string into an array
419     *
420     * @param string|null $value JSON string or null
421     * @return array|null Decoded array or null
422     */
423    private static function parseJson(?string $value): ?array
424    {
425        if ($value === null || $value === '') {
426            return null;
427        }
428
429        $decoded = json_decode($value, true);
430        return is_array($decoded) ? $decoded : null;
431    }
432
433    /**
434     * Validate the event configuration
435     *
436     * @return array Array of validation errors (empty if valid)
437     */
438    public function validate(): array
439    {
440        $errors = [];
441
442        // Name is required
443        if (empty($this->name)) {
444            $errors[] = 'Event name is required';
445        }
446
447        // Valid side
448        if (!in_array($this->side, [self::SIDE_BUY, self::SIDE_SALES])) {
449            $errors[] = 'Invalid event side';
450        }
451
452        // Buy-side requires flat amount
453        if ($this->side === self::SIDE_BUY && $this->earningFlatAmount === null) {
454            $errors[] = 'Buy-side events require a flat earning amount';
455        }
456
457        // Validate earning type configuration
458        switch ($this->earningType) {
459            case self::EARNING_TIERED:
460                if ($this->side === self::SIDE_BUY) {
461                    $errors[] = 'Buy-side events cannot use tiered earning';
462                }
463                if (empty($this->earningTiers)) {
464                    $errors[] = 'Tiered earning requires at least one tier';
465                }
466                break;
467
468            case self::EARNING_PERCENTAGE:
469                if ($this->side === self::SIDE_BUY) {
470                    $errors[] = 'Buy-side events cannot use percentage earning';
471                }
472                if ($this->earningPercentage === null) {
473                    $errors[] = 'Percentage earning requires an earning percentage';
474                }
475                break;
476        }
477
478        // End date must be after start date
479        if ($this->startDate !== null && $this->endDate !== null) {
480            if ($this->endDate < $this->startDate) {
481                $errors[] = 'End date must be after start date';
482            }
483        }
484
485        // Redemption end must be after redemption start
486        if ($this->redemptionStartDate !== null && $this->redemptionEndDate !== null) {
487            if ($this->redemptionEndDate < $this->redemptionStartDate) {
488                $errors[] = 'Redemption end date must be after redemption start date';
489            }
490        }
491
492        return $errors;
493    }
494}