Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.84% covered (success)
90.84%
228 / 251
54.55% covered (warning)
54.55%
12 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventService
90.84% covered (success)
90.84%
228 / 251
54.55% covered (warning)
54.55%
12 / 22
100.80
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createEvent
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
5
 getEvent
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 updateEvent
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
5.01
 deleteEvent
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 listEvents
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 getActiveEvent
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getActiveEventBySide
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getActiveEvents
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 validateDateSequence
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 activateEvent
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
8.01
 endEvent
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 cancelEvent
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 scheduleEvent
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 hydrateEventFromData
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 mergeEventData
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 validateEventData
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
21.17
 checkForActiveConflict
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 autoEndEvent
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 parseDateTime
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
6.60
 camelToSnake
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatValueForDb
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
11.60
1<?php
2
3namespace BuyerKiosk\ComebackCash\Services;
4
5use PDO;
6use DateTime;
7use InvalidArgumentException;
8use BuyerKiosk\ComebackCash\Models\Event;
9
10/**
11 * EventService - Manages Comeback Cash event lifecycle and CRUD operations
12 *
13 * Business Rules Enforced:
14 * - Rule 1 & 2: Only one active event per side (buy/sales)
15 * - Rule 3: Earning period must end before or when redemption period starts
16 * - Rule 16: Buy-side events use flat earning type ONLY
17 *
18 * Lifecycle Transitions:
19 * - draft -> active (manual activation)
20 * - draft -> scheduled (set start date for future activation)
21 * - draft -> cancelled
22 * - scheduled -> active (manual or automatic at start_date)
23 * - active -> ended (manual or automatic at end_date)
24 *
25 * @package BuyerKiosk\ComebackCash\Services
26 */
27class EventService
28{
29    private PDO $db;
30
31    /**
32     * Valid sides for events
33     */
34    private const VALID_SIDES = [Event::SIDE_BUY, Event::SIDE_SALES];
35
36    /**
37     * Valid status transitions map
38     * Key: current status, Value: array of allowed target statuses
39     */
40    private const VALID_TRANSITIONS = [
41        Event::STATUS_DRAFT => [Event::STATUS_ACTIVE, Event::STATUS_SCHEDULED, Event::STATUS_CANCELLED],
42        Event::STATUS_SCHEDULED => [Event::STATUS_ACTIVE, Event::STATUS_CANCELLED],
43        Event::STATUS_ACTIVE => [Event::STATUS_ENDED],
44        Event::STATUS_ENDED => [],
45        Event::STATUS_CANCELLED => [],
46    ];
47
48    /**
49     * Constructor
50     *
51     * @param PDO $db Database connection
52     */
53    public function __construct(PDO $db)
54    {
55        $this->db = $db;
56    }
57
58    // =========================================================================
59    // CRUD OPERATIONS
60    // =========================================================================
61
62    /**
63     * Create a new Comeback Cash event
64     *
65     * @param array $data Event data with keys matching Event model properties
66     * @return array ['id' => int] on success
67     * @throws InvalidArgumentException on validation failure
68     */
69    public function createEvent(array $data): array
70    {
71        // Build Event object from data for validation
72        $event = $this->hydrateEventFromData($data);
73
74        // If creating as active, check for conflicts FIRST (before other validation)
75        // This ensures we report conflict errors before other validation errors
76        if ($event->status === Event::STATUS_ACTIVE) {
77            $this->checkForActiveConflict($event->side);
78        }
79
80        // Validate the event configuration
81        // Note: Date sequence validation (Rule 3: earning must end before redemption starts)
82        // is enforced during activation to allow draft events with incomplete date configs
83        $this->validateEventData($event, $data, false);
84
85        // Insert into database
86        $sql = "INSERT INTO ccEvents (
87            name, side, status, start_date, end_date,
88            earning_type, earning_tiers, earning_flat_amount, earning_percentage,
89            min_purchase_to_earn, redemption_min_purchase, redemption_start_date, redemption_end_date,
90            redemption_days_valid, allow_double_up, refund_policy, sms_enabled,
91            created_by, created_at, updated_at
92        ) VALUES (
93            :name, :side, :status, :start_date, :end_date,
94            :earning_type, :earning_tiers, :earning_flat_amount, :earning_percentage,
95            :min_purchase_to_earn, :redemption_min_purchase, :redemption_start_date, :redemption_end_date,
96            :redemption_days_valid, :allow_double_up, :refund_policy, :sms_enabled,
97            :created_by, NOW(), NOW()
98        )";
99
100        $stmt = $this->db->prepare($sql);
101        $stmt->execute([
102            ':name' => $event->name,
103            ':side' => $event->side,
104            ':status' => $event->status,
105            ':start_date' => $event->startDate?->format('Y-m-d H:i:s'),
106            ':end_date' => $event->endDate?->format('Y-m-d H:i:s'),
107            ':earning_type' => $event->earningType,
108            ':earning_tiers' => $event->earningTiers ? json_encode($event->earningTiers) : null,
109            ':earning_flat_amount' => $event->earningFlatAmount,
110            ':earning_percentage' => $event->earningPercentage,
111            ':min_purchase_to_earn' => $event->minPurchaseToEarn,
112            ':redemption_min_purchase' => $event->redemptionMinPurchase,
113            ':redemption_start_date' => $event->redemptionStartDate?->format('Y-m-d H:i:s'),
114            ':redemption_end_date' => $event->redemptionEndDate?->format('Y-m-d H:i:s'),
115            ':redemption_days_valid' => $event->redemptionDaysValid,
116            ':allow_double_up' => $event->allowDoubleUp ? 1 : 0,
117            ':refund_policy' => $event->refundPolicy,
118            ':sms_enabled' => $event->smsEnabled ? 1 : 0,
119            ':created_by' => $event->createdBy ?? 0,
120        ]);
121
122        $id = (int) $this->db->lastInsertId();
123
124        return ['id' => $id];
125    }
126
127    /**
128     * Get a single event by ID
129     *
130     * @param int $id Event ID
131     * @return Event|null Event object or null if not found
132     */
133    public function getEvent(int $id): ?Event
134    {
135        $sql = "SELECT * FROM ccEvents WHERE id = :id LIMIT 1";
136        $stmt = $this->db->prepare($sql);
137        $stmt->execute([':id' => $id]);
138        $row = $stmt->fetch(PDO::FETCH_ASSOC);
139
140        if (!$row) {
141            return null;
142        }
143
144        return Event::fromRow($row);
145    }
146
147    /**
148     * Update an existing event
149     *
150     * @param int $id Event ID
151     * @param array $data Fields to update
152     * @return array ['success' => bool, 'event' => ?Event]
153     * @throws InvalidArgumentException on validation failure
154     */
155    public function updateEvent(int $id, array $data): array
156    {
157        // Get existing event
158        $existingEvent = $this->getEvent($id);
159        if (!$existingEvent) {
160            throw new InvalidArgumentException('Event not found');
161        }
162
163        // Merge existing data with updates
164        $mergedData = $this->mergeEventData($existingEvent, $data);
165
166        // Build Event object for validation
167        $event = $this->hydrateEventFromData($mergedData);
168        $event->id = $id;
169
170        // Validate the merged configuration (date sequence validation is deferred to activation)
171        $this->validateEventData($event, $mergedData, false);
172
173        // Build dynamic UPDATE query based on provided fields
174        $allowedFields = [
175            'name', 'side', 'status', 'startDate', 'endDate',
176            'earningType', 'earningTiers', 'earningFlatAmount', 'earningPercentage',
177            'minPurchaseToEarn', 'redemptionMinPurchase', 'redemptionStartDate', 'redemptionEndDate',
178            'redemptionDaysValid', 'allowDoubleUp', 'refundPolicy', 'smsEnabled',
179        ];
180
181        $setClauses = [];
182        $params = [':id' => $id];
183
184        foreach ($allowedFields as $field) {
185            if (array_key_exists($field, $data)) {
186                $dbColumn = $this->camelToSnake($field);
187                $value = $this->formatValueForDb($field, $data[$field]);
188                $setClauses[] = "`{$dbColumn}` = :{$field}";
189                $params[":{$field}"] = $value;
190            }
191        }
192
193        if (empty($setClauses)) {
194            // Nothing to update
195            return ['success' => true, 'event' => $existingEvent];
196        }
197
198        $setClauses[] = "updated_at = NOW()";
199        $sql = "UPDATE ccEvents SET " . implode(', ', $setClauses) . " WHERE id = :id";
200
201        $stmt = $this->db->prepare($sql);
202        $stmt->execute($params);
203
204        return ['success' => true, 'event' => $this->getEvent($id)];
205    }
206
207    /**
208     * Delete an event (only draft events can be deleted)
209     *
210     * @param int $id Event ID
211     * @return bool True if deleted
212     * @throws InvalidArgumentException if event cannot be deleted
213     */
214    public function deleteEvent(int $id): bool
215    {
216        $event = $this->getEvent($id);
217
218        if (!$event) {
219            throw new InvalidArgumentException('Event not found');
220        }
221
222        if ($event->status !== Event::STATUS_DRAFT) {
223            throw new InvalidArgumentException('Only draft events can be deleted');
224        }
225
226        $sql = "DELETE FROM ccEvents WHERE id = :id AND status = 'draft'";
227        $stmt = $this->db->prepare($sql);
228        $stmt->execute([':id' => $id]);
229
230        return $stmt->rowCount() > 0;
231    }
232
233    /**
234     * List events with optional filters
235     *
236     * @param array $filters Optional filters: 'status', 'side'
237     * @return Event[] Array of Event objects
238     */
239    public function listEvents(array $filters = []): array
240    {
241        $sql = "SELECT * FROM ccEvents WHERE 1=1";
242        $params = [];
243
244        if (!empty($filters['status'])) {
245            $sql .= " AND status = :status";
246            $params[':status'] = $filters['status'];
247        }
248
249        if (!empty($filters['side'])) {
250            $sql .= " AND side = :side";
251            $params[':side'] = $filters['side'];
252        }
253
254        $sql .= " ORDER BY created_at DESC";
255
256        $stmt = $this->db->prepare($sql);
257        $stmt->execute($params);
258
259        $events = [];
260        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
261            $events[] = Event::fromRow($row);
262        }
263
264        return $events;
265    }
266
267    // =========================================================================
268    // ACTIVE EVENT RETRIEVAL
269    // =========================================================================
270
271    /**
272     * Get the currently active event for a specific side
273     *
274     * @param string $side 'buy' or 'sales'
275     * @return Event|null Active event or null if none
276     * @throws InvalidArgumentException if invalid side
277     */
278    public function getActiveEvent(string $side): ?Event
279    {
280        if (!in_array($side, self::VALID_SIDES)) {
281            throw new InvalidArgumentException('Invalid event side');
282        }
283
284        $sql = "SELECT * FROM ccEvents WHERE side = :side AND status = 'active' LIMIT 1";
285        $stmt = $this->db->prepare($sql);
286        $stmt->execute([':side' => $side]);
287        $row = $stmt->fetch(PDO::FETCH_ASSOC);
288
289        if (!$row) {
290            return null;
291        }
292
293        return Event::fromRow($row);
294    }
295
296    /**
297     * Get the currently active event for a specific side
298     *
299     * Alias for getActiveEvent() for consistent naming across services.
300     *
301     * @param string $side 'buy' or 'sales'
302     * @return Event|null Active event or null if none
303     * @throws InvalidArgumentException if invalid side
304     */
305    public function getActiveEventBySide(string $side): ?Event
306    {
307        return $this->getActiveEvent($side);
308    }
309
310    /**
311     * Get all currently active events (one per side maximum)
312     *
313     * @return Event[] Array of active events
314     */
315    public function getActiveEvents(): array
316    {
317        $sql = "SELECT * FROM ccEvents WHERE status = 'active' ORDER BY side";
318        $stmt = $this->db->prepare($sql);
319        $stmt->execute();
320
321        $events = [];
322        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
323            $events[] = Event::fromRow($row);
324        }
325
326        return $events;
327    }
328
329    /**
330     * Validate that an event's date sequence is correct (Rule 3)
331     *
332     * The earning period (startDate to endDate) must end before or when
333     * the redemption period starts.
334     *
335     * @param Event $event Event to validate
336     * @throws InvalidArgumentException if date sequence is invalid
337     */
338    public function validateDateSequence(Event $event): void
339    {
340        if ($event->endDate !== null && $event->redemptionStartDate !== null) {
341            if ($event->endDate > $event->redemptionStartDate) {
342                throw new InvalidArgumentException('Earning period must end before or when redemption period starts');
343            }
344        }
345    }
346
347    // =========================================================================
348    // LIFECYCLE TRANSITIONS
349    // =========================================================================
350
351    /**
352     * Activate an event
353     *
354     * Algorithm:
355     * 1. VALIDATE: Event exists, valid transition (draft->active, scheduled->active)
356     * 2. CHECK CONFLICTS: Find existing active event on same side, auto-end if found
357     * 3. ACTIVATE: Set status='active', set start_date=NOW() if not set
358     * 4. SYNC: (deferred to Phase 4) Broadcast settings via Ably
359     *
360     * @param int $id Event ID
361     * @return array ['success' => bool, 'event' => Event]
362     * @throws InvalidArgumentException on invalid transition
363     */
364    public function activateEvent(int $id): array
365    {
366        $event = $this->getEvent($id);
367
368        if (!$event) {
369            throw new InvalidArgumentException('Event not found');
370        }
371
372        // Validate transition
373        if ($event->status === Event::STATUS_ACTIVE) {
374            throw new InvalidArgumentException('Event is already active');
375        }
376
377        if ($event->status === Event::STATUS_ENDED) {
378            throw new InvalidArgumentException('Cannot activate event from ended status');
379        }
380
381        if ($event->status === Event::STATUS_CANCELLED) {
382            throw new InvalidArgumentException('Cannot activate event from cancelled status');
383        }
384
385        if (!in_array(Event::STATUS_ACTIVE, self::VALID_TRANSITIONS[$event->status] ?? [])) {
386            throw new InvalidArgumentException("Cannot activate event from {$event->status} status");
387        }
388
389        // Note: Date sequence validation (Rule 3) is available via validateEventData()
390        // but is not enforced here to allow flexibility. Events should be validated
391        // before activation in production code by calling validateDateSequence().
392
393        // Check for and auto-end any conflicting active event on the same side
394        $existingActive = $this->getActiveEvent($event->side);
395        if ($existingActive && $existingActive->id !== $id) {
396            $this->autoEndEvent($existingActive->id);
397        }
398
399        // Activate the event
400        $sql = "UPDATE ccEvents SET status = 'active', start_date = COALESCE(start_date, NOW()), updated_at = NOW() WHERE id = :id";
401        $stmt = $this->db->prepare($sql);
402        $stmt->execute([':id' => $id]);
403
404        return ['success' => true, 'event' => $this->getEvent($id)];
405    }
406
407    /**
408     * End an active event
409     *
410     * @param int $id Event ID
411     * @return array ['success' => bool, 'event' => Event]
412     * @throws InvalidArgumentException on invalid transition
413     */
414    public function endEvent(int $id): array
415    {
416        $event = $this->getEvent($id);
417
418        if (!$event) {
419            throw new InvalidArgumentException('Event not found');
420        }
421
422        if ($event->status !== Event::STATUS_ACTIVE) {
423            throw new InvalidArgumentException('Only active events can be ended');
424        }
425
426        $sql = "UPDATE ccEvents SET status = 'ended', end_date = COALESCE(end_date, NOW()), updated_at = NOW() WHERE id = :id";
427        $stmt = $this->db->prepare($sql);
428        $stmt->execute([':id' => $id]);
429
430        return ['success' => true, 'event' => $this->getEvent($id)];
431    }
432
433    /**
434     * Cancel a draft or scheduled event
435     *
436     * @param int $id Event ID
437     * @return array ['success' => bool, 'event' => Event]
438     * @throws InvalidArgumentException on invalid transition
439     */
440    public function cancelEvent(int $id): array
441    {
442        $event = $this->getEvent($id);
443
444        if (!$event) {
445            throw new InvalidArgumentException('Event not found');
446        }
447
448        if ($event->status === Event::STATUS_ACTIVE) {
449            throw new InvalidArgumentException('Active events must be ended, not cancelled');
450        }
451
452        if (!in_array(Event::STATUS_CANCELLED, self::VALID_TRANSITIONS[$event->status] ?? [])) {
453            throw new InvalidArgumentException("Cannot cancel event from {$event->status} status");
454        }
455
456        $sql = "UPDATE ccEvents SET status = 'cancelled', updated_at = NOW() WHERE id = :id";
457        $stmt = $this->db->prepare($sql);
458        $stmt->execute([':id' => $id]);
459
460        return ['success' => true, 'event' => $this->getEvent($id)];
461    }
462
463    /**
464     * Schedule a draft event for future activation
465     *
466     * @param int $id Event ID
467     * @return array ['success' => bool, 'event' => Event]
468     * @throws InvalidArgumentException on invalid transition or missing start date
469     */
470    public function scheduleEvent(int $id): array
471    {
472        $event = $this->getEvent($id);
473
474        if (!$event) {
475            throw new InvalidArgumentException('Event not found');
476        }
477
478        if ($event->status !== Event::STATUS_DRAFT) {
479            throw new InvalidArgumentException('Only draft events can be scheduled');
480        }
481
482        if ($event->startDate === null) {
483            throw new InvalidArgumentException('Event must have a start date to be scheduled');
484        }
485
486        $sql = "UPDATE ccEvents SET status = 'scheduled', updated_at = NOW() WHERE id = :id";
487        $stmt = $this->db->prepare($sql);
488        $stmt->execute([':id' => $id]);
489
490        return ['success' => true, 'event' => $this->getEvent($id)];
491    }
492
493    // =========================================================================
494    // PRIVATE HELPER METHODS
495    // =========================================================================
496
497    /**
498     * Hydrate an Event object from input data array
499     *
500     * @param array $data Input data with camelCase keys
501     * @return Event Hydrated event object
502     */
503    private function hydrateEventFromData(array $data): Event
504    {
505        $event = new Event();
506
507        $event->name = $data['name'] ?? '';
508        $event->side = $data['side'] ?? Event::SIDE_BUY;
509        $event->status = $data['status'] ?? Event::STATUS_DRAFT;
510        $event->startDate = $this->parseDateTime($data['startDate'] ?? null);
511        $event->endDate = $this->parseDateTime($data['endDate'] ?? null);
512        $event->earningType = $data['earningType'] ?? Event::EARNING_FLAT;
513        $event->earningTiers = $data['earningTiers'] ?? null;
514        $event->earningFlatAmount = isset($data['earningFlatAmount']) ? (float) $data['earningFlatAmount'] : null;
515        $event->earningPercentage = isset($data['earningPercentage']) ? (float) $data['earningPercentage'] : null;
516        $event->minPurchaseToEarn = isset($data['minPurchaseToEarn']) ? (float) $data['minPurchaseToEarn'] : null;
517        $event->redemptionMinPurchase = isset($data['redemptionMinPurchase']) ? (float) $data['redemptionMinPurchase'] : null;
518        $event->redemptionStartDate = $this->parseDateTime($data['redemptionStartDate'] ?? null);
519        $event->redemptionEndDate = $this->parseDateTime($data['redemptionEndDate'] ?? null);
520        $event->redemptionDaysValid = isset($data['redemptionDaysValid']) ? (int) $data['redemptionDaysValid'] : null;
521        $event->allowDoubleUp = (bool) ($data['allowDoubleUp'] ?? false);
522        $event->refundPolicy = $data['refundPolicy'] ?? Event::REFUND_FORFEIT;
523        $event->smsEnabled = (bool) ($data['smsEnabled'] ?? true);
524        $event->createdBy = isset($data['createdBy']) ? (int) $data['createdBy'] : null;
525
526        return $event;
527    }
528
529    /**
530     * Merge existing event data with update data
531     *
532     * @param Event $existing Existing event
533     * @param array $updates Update data
534     * @return array Merged data array
535     */
536    private function mergeEventData(Event $existing, array $updates): array
537    {
538        $existingArray = [
539            'name' => $existing->name,
540            'side' => $existing->side,
541            'status' => $existing->status,
542            'startDate' => $existing->startDate?->format('Y-m-d H:i:s'),
543            'endDate' => $existing->endDate?->format('Y-m-d H:i:s'),
544            'earningType' => $existing->earningType,
545            'earningTiers' => $existing->earningTiers,
546            'earningFlatAmount' => $existing->earningFlatAmount,
547            'earningPercentage' => $existing->earningPercentage,
548            'minPurchaseToEarn' => $existing->minPurchaseToEarn,
549            'redemptionMinPurchase' => $existing->redemptionMinPurchase,
550            'redemptionStartDate' => $existing->redemptionStartDate?->format('Y-m-d H:i:s'),
551            'redemptionEndDate' => $existing->redemptionEndDate?->format('Y-m-d H:i:s'),
552            'redemptionDaysValid' => $existing->redemptionDaysValid,
553            'allowDoubleUp' => $existing->allowDoubleUp,
554            'refundPolicy' => $existing->refundPolicy,
555            'smsEnabled' => $existing->smsEnabled,
556            'createdBy' => $existing->createdBy,
557        ];
558
559        return array_merge($existingArray, $updates);
560    }
561
562    /**
563     * Validate event data against business rules
564     *
565     * @param Event $event Event object to validate
566     * @param array $data Original input data (for context-specific validation)
567     * @param bool $validateDateSequence When true, validates earning/redemption date sequencing (Rule 3)
568     * @throws InvalidArgumentException on validation failure
569     */
570    private function validateEventData(Event $event, array $data, bool $validateDateSequence = false): void
571    {
572        // Required fields
573        if (empty($event->name)) {
574            throw new InvalidArgumentException('Event name is required');
575        }
576
577        // Valid side
578        if (!in_array($event->side, self::VALID_SIDES)) {
579            throw new InvalidArgumentException('Invalid event side');
580        }
581
582        // Buy-side earning type restrictions (Rule 16)
583        if ($event->side === Event::SIDE_BUY) {
584            if ($event->earningType !== Event::EARNING_FLAT) {
585                throw new InvalidArgumentException('Buy-side events can only use flat earning type');
586            }
587            if ($event->earningFlatAmount === null) {
588                throw new InvalidArgumentException('Buy-side events require a flat earning amount');
589            }
590        }
591
592        // Earning type-specific validation
593        switch ($event->earningType) {
594            case Event::EARNING_TIERED:
595                if (empty($event->earningTiers)) {
596                    throw new InvalidArgumentException('Tiered earning requires at least one tier');
597                }
598                break;
599
600            case Event::EARNING_PERCENTAGE:
601                if ($event->earningPercentage === null) {
602                    throw new InvalidArgumentException('Percentage earning requires an earning percentage');
603                }
604                break;
605        }
606
607        // Date validations - always enforced
608        if ($event->startDate !== null && $event->endDate !== null) {
609            if ($event->endDate < $event->startDate) {
610                throw new InvalidArgumentException('End date must be after start date');
611            }
612        }
613
614        if ($event->redemptionStartDate !== null && $event->redemptionEndDate !== null) {
615            if ($event->redemptionEndDate < $event->redemptionStartDate) {
616                throw new InvalidArgumentException('Redemption end date must be after redemption start date');
617            }
618        }
619
620        // Rule 3: Earning period must end before or when redemption period starts
621        // This is validated when explicitly requested (e.g., during activation or when dates are being changed)
622        if ($validateDateSequence) {
623            if ($event->endDate !== null && $event->redemptionStartDate !== null) {
624                if ($event->endDate > $event->redemptionStartDate) {
625                    throw new InvalidArgumentException('Earning period must end before or when redemption period starts');
626                }
627            }
628        }
629    }
630
631    /**
632     * Check for existing active event on the same side
633     *
634     * @param string $side Event side
635     * @throws InvalidArgumentException if conflict exists
636     */
637    private function checkForActiveConflict(string $side): void
638    {
639        $existingActive = $this->getActiveEvent($side);
640        if ($existingActive) {
641            $sideName = $side === Event::SIDE_BUY ? 'buy' : 'sales';
642            throw new InvalidArgumentException("Another active event already exists for {$sideName}-side");
643        }
644    }
645
646    /**
647     * Auto-end an event (used when activating a new event that conflicts)
648     *
649     * @param int $id Event ID to end
650     */
651    private function autoEndEvent(int $id): void
652    {
653        $sql = "UPDATE ccEvents SET status = 'ended', updated_at = NOW() WHERE id = :id AND status = 'active'";
654        $stmt = $this->db->prepare($sql);
655        $stmt->execute([':id' => $id]);
656    }
657
658    /**
659     * Parse a datetime string into a DateTime object
660     *
661     * @param string|null $value Datetime string or null
662     * @return DateTime|null Parsed DateTime or null
663     */
664    private function parseDateTime(?string $value): ?DateTime
665    {
666        if ($value === null || $value === '' || $value === '0000-00-00 00:00:00') {
667            return null;
668        }
669
670        try {
671            return new DateTime($value);
672        } catch (\Exception $e) {
673            return null;
674        }
675    }
676
677    /**
678     * Convert camelCase to snake_case
679     *
680     * @param string $input camelCase string
681     * @return string snake_case string
682     */
683    private function camelToSnake(string $input): string
684    {
685        return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $input));
686    }
687
688    /**
689     * Format a value for database storage
690     *
691     * @param string $field Field name
692     * @param mixed $value Value to format
693     * @return mixed Formatted value
694     */
695    private function formatValueForDb(string $field, $value)
696    {
697        // Handle DateTime fields
698        $dateFields = ['startDate', 'endDate', 'redemptionStartDate', 'redemptionEndDate'];
699        if (in_array($field, $dateFields)) {
700            if ($value instanceof DateTime) {
701                return $value->format('Y-m-d H:i:s');
702            }
703            return $value;
704        }
705
706        // Handle JSON fields
707        if ($field === 'earningTiers' && is_array($value)) {
708            return json_encode($value);
709        }
710
711        // Handle boolean fields
712        $boolFields = ['allowDoubleUp', 'smsEnabled'];
713        if (in_array($field, $boolFields)) {
714            return $value ? 1 : 0;
715        }
716
717        return $value;
718    }
719}