Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 360
0.00% covered (danger)
0.00%
0 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComebackCashAdapter
0.00% covered (danger)
0.00%
0 / 360
0.00% covered (danger)
0.00%
0 / 17
5256
0.00% covered (danger)
0.00%
0 / 1
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 create
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
42
 syncDates
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
42
 activate
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 deactivate
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 delete
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 getStatus
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
110
 validateConfig
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
342
 getDefaultConfig
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
2
 calculateCCEventDates
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 buildCCEventName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 prepareEarningConfig
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 checkForConflicts
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 getCCEvent
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 autoEndActiveEventOnSide
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getCouponCount
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getCouponStats
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\EventManagement\Adapters;
4
5use PDO;
6use DateTime;
7use DateInterval;
8use BuyerKiosk\EventManagement\Models\Event;
9use BuyerKiosk\EventManagement\Models\EventIntegration;
10use BuyerKiosk\ComebackCash\Models\Event as CCEvent;
11
12/**
13 * ComebackCashAdapter - Integrates Event Management with Comeback Cash system
14 *
15 * Creates and manages ccEvents records linked to unified event management events.
16 * Handles:
17 * - Creating ccEvents with earning/redemption windows calculated from event dates
18 * - Syncing dates when event timeline changes
19 * - Activating/deactivating coupon events
20 * - Reporting coupon metrics (issued, redeemed, redemption rate)
21 *
22 * IMPORTANT: Respects existing ccEvents conflict rules:
23 * - Only one active event per side (buy/sales) at a time
24 * - Earning period must end before redemption period starts
25 *
26 * @package BuyerKiosk\EventManagement\Adapters
27 */
28class ComebackCashAdapter extends AbstractAdapter
29{
30    /**
31     * Valid earning types for ccEvents
32     */
33    private const VALID_EARNING_TYPES = [
34        CCEvent::EARNING_TIERED,
35        CCEvent::EARNING_FLAT,
36        CCEvent::EARNING_PERCENTAGE,
37    ];
38
39    /**
40     * Valid sides for ccEvents
41     */
42    private const VALID_SIDES = [
43        CCEvent::SIDE_BUY,
44        CCEvent::SIDE_SALES,
45    ];
46
47    /**
48     * Get the integration type this adapter handles
49     *
50     * @return string
51     */
52    public function getType(): string
53    {
54        return EventIntegration::TYPE_COMEBACK_CASH;
55    }
56
57    /**
58     * Create a linked ccEvents record
59     *
60     * Creates a new Comeback Cash event with:
61     * - earning/redemption windows calculated from event dates + relativeDays
62     * - eventId set for traceability
63     *
64     * @param Event $event The unified event
65     * @param array $config Integration-specific configuration
66     * @return int The ccEvents.id of the created record
67     * @throws IntegrationException On failure
68     */
69    public function create(Event $event, array $config): int
70    {
71        // Validate configuration first
72        $errors = $this->validateConfig($config);
73        if (!empty($errors)) {
74            throw IntegrationException::invalidConfig($this->getType(), $errors);
75        }
76
77        // Check for date conflicts with existing active events
78        $conflictErrors = $this->checkForConflicts($event, $config, null);
79        if (!empty($conflictErrors)) {
80            throw IntegrationException::createFailed(
81                $this->getType(),
82                implode('; ', $conflictErrors),
83                ['event_id' => $event->id, 'config' => $config]
84            );
85        }
86
87        try {
88            $this->beginTransaction();
89
90            // Calculate dates from event dates + relative offsets
91            $dates = $this->calculateCCEventDates($event, $config);
92
93            // Build ccEvent name from unified event name
94            $ccEventName = $this->buildCCEventName($event, $config);
95
96            // Prepare earning configuration based on type
97            $earningConfig = $this->prepareEarningConfig($config);
98
99            // Insert into ccEvents
100            $sql = "INSERT INTO ccEvents (
101                name, side, status, start_date, end_date,
102                earning_type, earning_tiers, earning_flat_amount, earning_percentage,
103                min_purchase_to_earn, redemption_min_purchase, redemption_start_date, redemption_end_date,
104                redemption_days_valid, allow_double_up, refund_policy, sms_enabled,
105                event_id, created_by, created_at, updated_at
106            ) VALUES (
107                :name, :side, :status, :start_date, :end_date,
108                :earning_type, :earning_tiers, :earning_flat_amount, :earning_percentage,
109                :min_purchase_to_earn, :redemption_min_purchase, :redemption_start_date, :redemption_end_date,
110                :redemption_days_valid, :allow_double_up, :refund_policy, :sms_enabled,
111                :event_id, :created_by, NOW(), NOW()
112            )";
113
114            $stmt = $this->db->prepare($sql);
115            $stmt->execute([
116                ':name' => $ccEventName,
117                ':side' => $config['side'],
118                ':status' => CCEvent::STATUS_DRAFT, // Always start as draft
119                ':start_date' => $this->formatDateForDb($dates['earningStart']),
120                ':end_date' => $this->formatDateForDb($dates['earningEnd']),
121                ':earning_type' => $earningConfig['type'],
122                ':earning_tiers' => $earningConfig['tiers'],
123                ':earning_flat_amount' => $earningConfig['flatAmount'],
124                ':earning_percentage' => $earningConfig['percentage'],
125                ':min_purchase_to_earn' => $this->getConfigValue($config, 'minPurchaseToEarn'),
126                ':redemption_min_purchase' => $this->getConfigValue($config, 'redemptionMinPurchase'),
127                ':redemption_start_date' => $this->formatDateForDb($dates['redemptionStart']),
128                ':redemption_end_date' => $this->formatDateForDb($dates['redemptionEnd']),
129                ':redemption_days_valid' => $this->getConfigValue($config, 'redemptionDaysValid'),
130                ':allow_double_up' => $this->getConfigValue($config, 'allowDoubleUp', false) ? 1 : 0,
131                ':refund_policy' => $this->getConfigValue($config, 'refundPolicy', CCEvent::REFUND_FORFEIT),
132                ':sms_enabled' => $this->getConfigValue($config, 'smsEnabled', true) ? 1 : 0,
133                ':event_id' => $event->id,
134                ':created_by' => $event->createdBy ?? 0,
135            ]);
136
137            $ccEventId = (int) $this->db->lastInsertId();
138
139            $this->commit();
140
141            return $ccEventId;
142
143        } catch (\PDOException $e) {
144            $this->rollback();
145            $this->logError('Failed to create ccEvent', [
146                'event_id' => $event->id,
147                'error' => $e->getMessage(),
148            ]);
149            throw IntegrationException::createFailed(
150                $this->getType(),
151                'Database error: ' . $e->getMessage(),
152                ['event_id' => $event->id],
153                $e
154            );
155        }
156    }
157
158    /**
159     * Update the linked ccEvents record when event dates change
160     *
161     * Recalculates earning/redemption windows based on new event dates.
162     *
163     * @param Event $event The updated event with new dates
164     * @param EventIntegration $integration The integration record with foreignId
165     * @throws IntegrationException On failure
166     */
167    public function syncDates(Event $event, EventIntegration $integration): void
168    {
169        $ccEventId = $integration->foreignId;
170
171        // Verify the ccEvent exists
172        if (!$this->recordExists('ccEvents', $ccEventId)) {
173            throw IntegrationException::targetNotFound($this->getType(), $ccEventId);
174        }
175
176        // Get current ccEvent to check status
177        $ccEvent = $this->getCCEvent($ccEventId);
178        if ($ccEvent === null) {
179            throw IntegrationException::targetNotFound($this->getType(), $ccEventId);
180        }
181
182        // Cannot sync dates for ended or cancelled events
183        if (in_array($ccEvent->status, [CCEvent::STATUS_ENDED, CCEvent::STATUS_CANCELLED])) {
184            throw IntegrationException::syncDatesFailed(
185                $this->getType(),
186                $ccEventId,
187                "Cannot sync dates for ccEvent in status: {$ccEvent->status}"
188            );
189        }
190
191        // Get configuration from integration
192        $config = $integration->config ?? [];
193
194        // Check for conflicts with new dates
195        $conflictErrors = $this->checkForConflicts($event, $config, $ccEventId);
196        if (!empty($conflictErrors)) {
197            throw IntegrationException::syncDatesFailed(
198                $this->getType(),
199                $ccEventId,
200                implode('; ', $conflictErrors)
201            );
202        }
203
204        try {
205            // Calculate new dates
206            $dates = $this->calculateCCEventDates($event, $config);
207
208            $sql = "UPDATE ccEvents SET
209                start_date = :start_date,
210                end_date = :end_date,
211                redemption_start_date = :redemption_start_date,
212                redemption_end_date = :redemption_end_date,
213                updated_at = NOW()
214                WHERE id = :id";
215
216            $stmt = $this->db->prepare($sql);
217            $stmt->execute([
218                ':start_date' => $this->formatDateForDb($dates['earningStart']),
219                ':end_date' => $this->formatDateForDb($dates['earningEnd']),
220                ':redemption_start_date' => $this->formatDateForDb($dates['redemptionStart']),
221                ':redemption_end_date' => $this->formatDateForDb($dates['redemptionEnd']),
222                ':id' => $ccEventId,
223            ]);
224
225        } catch (\PDOException $e) {
226            $this->logError('Failed to sync ccEvent dates', [
227                'ccEventId' => $ccEventId,
228                'error' => $e->getMessage(),
229            ]);
230            throw IntegrationException::syncDatesFailed(
231                $this->getType(),
232                $ccEventId,
233                'Database error: ' . $e->getMessage(),
234                $e
235            );
236        }
237    }
238
239    /**
240     * Activate the ccEvent (transition to active status)
241     *
242     * Will auto-end any existing active event on the same side.
243     *
244     * @param Event $event The event being activated
245     * @param EventIntegration $integration The integration to activate
246     * @throws IntegrationException On failure
247     */
248    public function activate(Event $event, EventIntegration $integration): void
249    {
250        $ccEventId = $integration->foreignId;
251
252        $ccEvent = $this->getCCEvent($ccEventId);
253        if ($ccEvent === null) {
254            throw IntegrationException::targetNotFound($this->getType(), $ccEventId);
255        }
256
257        // Check if already active
258        if ($ccEvent->status === CCEvent::STATUS_ACTIVE) {
259            return; // Already active, nothing to do
260        }
261
262        // Can only activate from draft or scheduled
263        if (!in_array($ccEvent->status, [CCEvent::STATUS_DRAFT, CCEvent::STATUS_SCHEDULED])) {
264            throw IntegrationException::activateFailed(
265                $this->getType(),
266                $ccEventId,
267                "Cannot activate ccEvent from status: {$ccEvent->status}"
268            );
269        }
270
271        try {
272            $this->beginTransaction();
273
274            // Auto-end any existing active event on the same side
275            $this->autoEndActiveEventOnSide($ccEvent->side, $ccEventId);
276
277            // Activate the event
278            $sql = "UPDATE ccEvents SET
279                status = 'active',
280                start_date = COALESCE(start_date, NOW()),
281                updated_at = NOW()
282                WHERE id = :id";
283
284            $stmt = $this->db->prepare($sql);
285            $stmt->execute([':id' => $ccEventId]);
286
287            $this->commit();
288
289        } catch (\PDOException $e) {
290            $this->rollback();
291            $this->logError('Failed to activate ccEvent', [
292                'ccEventId' => $ccEventId,
293                'error' => $e->getMessage(),
294            ]);
295            throw IntegrationException::activateFailed(
296                $this->getType(),
297                $ccEventId,
298                'Database error: ' . $e->getMessage(),
299                $e
300            );
301        }
302    }
303
304    /**
305     * Deactivate/end the ccEvent
306     *
307     * Gracefully handles active coupons by keeping them valid until expiration.
308     * Sets event status to 'ended' to stop new coupon issuance.
309     *
310     * @param Event $event The event being deactivated
311     * @param EventIntegration $integration The integration to deactivate
312     * @throws IntegrationException On failure
313     */
314    public function deactivate(Event $event, EventIntegration $integration): void
315    {
316        $ccEventId = $integration->foreignId;
317
318        $ccEvent = $this->getCCEvent($ccEventId);
319        if ($ccEvent === null) {
320            throw IntegrationException::targetNotFound($this->getType(), $ccEventId);
321        }
322
323        // Already in terminal state
324        if (in_array($ccEvent->status, [CCEvent::STATUS_ENDED, CCEvent::STATUS_CANCELLED])) {
325            return; // Already deactivated
326        }
327
328        try {
329            // Determine target status based on current state
330            // Active events get "ended", draft/scheduled events get "cancelled"
331            $targetStatus = $ccEvent->status === CCEvent::STATUS_ACTIVE
332                ? CCEvent::STATUS_ENDED
333                : CCEvent::STATUS_CANCELLED;
334
335            $sql = "UPDATE ccEvents SET
336                status = :status,
337                end_date = COALESCE(end_date, NOW()),
338                updated_at = NOW()
339                WHERE id = :id";
340
341            $stmt = $this->db->prepare($sql);
342            $stmt->execute([
343                ':status' => $targetStatus,
344                ':id' => $ccEventId,
345            ]);
346
347            // Note: Active coupons remain valid until their individual expiration dates
348            // This is intentional to honor commitments to customers
349
350        } catch (\PDOException $e) {
351            $this->logError('Failed to deactivate ccEvent', [
352                'ccEventId' => $ccEventId,
353                'error' => $e->getMessage(),
354            ]);
355            throw IntegrationException::deactivateFailed(
356                $this->getType(),
357                $ccEventId,
358                'Database error: ' . $e->getMessage(),
359                $e
360            );
361        }
362    }
363
364    /**
365     * Delete the ccEvent record
366     *
367     * Only draft events can be deleted. Events with issued coupons cannot be deleted.
368     *
369     * @param EventIntegration $integration The integration to delete
370     * @throws IntegrationException On failure
371     */
372    public function delete(EventIntegration $integration): void
373    {
374        $ccEventId = $integration->foreignId;
375
376        $ccEvent = $this->getCCEvent($ccEventId);
377        if ($ccEvent === null) {
378            // Already deleted, nothing to do
379            return;
380        }
381
382        // Only draft events can be deleted
383        if ($ccEvent->status !== CCEvent::STATUS_DRAFT) {
384            throw IntegrationException::deleteFailed(
385                $this->getType(),
386                $ccEventId,
387                'Only draft ccEvents can be deleted'
388            );
389        }
390
391        // Check if any coupons exist for this event
392        $couponCount = $this->getCouponCount($ccEventId);
393        if ($couponCount > 0) {
394            throw IntegrationException::deleteFailed(
395                $this->getType(),
396                $ccEventId,
397                "Cannot delete ccEvent with {$couponCount} issued coupons"
398            );
399        }
400
401        try {
402            $sql = "DELETE FROM ccEvents WHERE id = :id AND status = 'draft'";
403            $stmt = $this->db->prepare($sql);
404            $stmt->execute([':id' => $ccEventId]);
405
406        } catch (\PDOException $e) {
407            $this->logError('Failed to delete ccEvent', [
408                'ccEventId' => $ccEventId,
409                'error' => $e->getMessage(),
410            ]);
411            throw IntegrationException::deleteFailed(
412                $this->getType(),
413                $ccEventId,
414                'Database error: ' . $e->getMessage(),
415                $e
416            );
417        }
418    }
419
420    /**
421     * Get status information from the ccEvent
422     *
423     * Returns coupon metrics: issued count, redeemed count, redemption rate, total value.
424     *
425     * @param EventIntegration $integration The integration to check
426     * @return array Status details
427     */
428    public function getStatus(EventIntegration $integration): array
429    {
430        $ccEventId = $integration->foreignId;
431
432        try {
433            $ccEvent = $this->getCCEvent($ccEventId);
434            if ($ccEvent === null) {
435                return $this->buildStatusResponse('not_found', [
436                    'error' => 'ccEvent not found',
437                ]);
438            }
439
440            // Get coupon statistics
441            $stats = $this->getCouponStats($ccEventId);
442
443            // Map ccEvent status to integration status
444            $integrationStatus = match ($ccEvent->status) {
445                CCEvent::STATUS_DRAFT => EventIntegration::STATUS_PENDING,
446                CCEvent::STATUS_SCHEDULED => EventIntegration::STATUS_PENDING,
447                CCEvent::STATUS_ACTIVE => EventIntegration::STATUS_ACTIVE,
448                CCEvent::STATUS_ENDED => EventIntegration::STATUS_COMPLETED,
449                CCEvent::STATUS_CANCELLED => EventIntegration::STATUS_COMPLETED,
450                default => EventIntegration::STATUS_PENDING,
451            };
452
453            return $this->buildStatusResponse($integrationStatus, [
454                'ccEventStatus' => $ccEvent->status,
455                'ccEventName' => $ccEvent->name,
456                'side' => $ccEvent->side,
457                'earningType' => $ccEvent->earningType,
458                'couponsIssued' => $stats['issued'],
459                'couponsRedeemed' => $stats['redeemed'],
460                'couponsActive' => $stats['active'],
461                'couponsExpired' => $stats['expired'],
462                'totalValueIssued' => $stats['totalValueIssued'],
463                'totalValueRedeemed' => $stats['totalValueRedeemed'],
464                'redemptionRate' => $stats['issued'] > 0
465                    ? round(($stats['redeemed'] / $stats['issued']) * 100, 2)
466                    : 0,
467                'startDate' => $ccEvent->startDate?->format('Y-m-d H:i:s'),
468                'endDate' => $ccEvent->endDate?->format('Y-m-d H:i:s'),
469                'redemptionStartDate' => $ccEvent->redemptionStartDate?->format('Y-m-d H:i:s'),
470                'redemptionEndDate' => $ccEvent->redemptionEndDate?->format('Y-m-d H:i:s'),
471            ], $this->parseDate($ccEvent->updatedAt));
472
473        } catch (\PDOException $e) {
474            $this->logError('Failed to get ccEvent status', [
475                'ccEventId' => $ccEventId,
476                'error' => $e->getMessage(),
477            ]);
478            return $this->buildStatusResponse('failed', [
479                'error' => 'Failed to retrieve status',
480            ]);
481        }
482    }
483
484    /**
485     * Validate configuration before creating integration
486     *
487     * Validates:
488     * - Required fields (side, earningType, couponValue)
489     * - Valid side and earningType values
490     * - Buy-side can only use flat earning type
491     * - Earning period must end before redemption starts
492     *
493     * @param array $config Configuration to validate
494     * @return array Array of validation errors (empty if valid)
495     */
496    public function validateConfig(array $config): array
497    {
498        $errors = [];
499
500        // Required fields
501        $requiredFields = ['side', 'earningType', 'couponValue'];
502        $errors = array_merge($errors, $this->validateRequiredKeys($config, $requiredFields));
503
504        // Validate side
505        $side = $config['side'] ?? null;
506        if ($side !== null && !in_array($side, self::VALID_SIDES)) {
507            $errors[] = 'Invalid side: must be "buy" or "sales"';
508        }
509
510        // Validate earningType
511        $earningType = $config['earningType'] ?? null;
512        if ($earningType !== null && !in_array($earningType, self::VALID_EARNING_TYPES)) {
513            $errors[] = 'Invalid earningType: must be "tiered", "flat", or "percentage"';
514        }
515
516        // Buy-side restrictions (Rule 16)
517        if ($side === CCEvent::SIDE_BUY) {
518            if ($earningType !== null && $earningType !== CCEvent::EARNING_FLAT) {
519                $errors[] = 'Buy-side events can only use flat earning type';
520            }
521        }
522
523        // Validate earning configuration based on type
524        if ($earningType === CCEvent::EARNING_TIERED) {
525            if (empty($config['earningTiers'])) {
526                $errors[] = 'Tiered earning requires earningTiers configuration';
527            }
528        } elseif ($earningType === CCEvent::EARNING_PERCENTAGE) {
529            if (!isset($config['earningPercentage']) || $config['earningPercentage'] <= 0) {
530                $errors[] = 'Percentage earning requires a positive earningPercentage';
531            }
532        }
533
534        // Validate couponValue is positive
535        if (isset($config['couponValue']) && $config['couponValue'] <= 0) {
536            $errors[] = 'Coupon value must be positive';
537        }
538
539        // Validate relative days sequence (Rule 3: earning must end before redemption starts)
540        $earningEnd = $config['earningEndRelative'] ?? 0;
541        $redemptionStart = $config['redemptionStartRelative'] ?? 0;
542        if ($earningEnd > $redemptionStart) {
543            $errors[] = 'Earning period must end before or when redemption period starts';
544        }
545
546        // Validate maxCoupons if provided
547        if (isset($config['maxCoupons']) && $config['maxCoupons'] <= 0) {
548            $errors[] = 'Maximum coupons must be positive if specified';
549        }
550
551        return $errors;
552    }
553
554    /**
555     * Get the default configuration for Comeback Cash integration
556     *
557     * @return array Default configuration values
558     */
559    public function getDefaultConfig(): array
560    {
561        return [
562            'side' => CCEvent::SIDE_BUY,
563            'earningType' => CCEvent::EARNING_FLAT,
564            'earningValue' => 5.00,          // $5 coupon
565            'earningTiers' => null,
566            'earningPercentage' => null,
567            'earningStartRelative' => 0,      // Start earning on event start
568            'earningEndRelative' => 0,        // End earning on event end
569            'redemptionStartRelative' => 1,   // Redemption starts 1 day after event end
570            'redemptionEndRelative' => 14,    // Redemption ends 14 days after event end
571            'minPurchaseToEarn' => null,
572            'redemptionMinPurchase' => null,
573            'redemptionDaysValid' => null,    // Use event dates instead
574            'maxCoupons' => null,
575            'couponValue' => 5.00,
576            'allowDoubleUp' => false,
577            'refundPolicy' => CCEvent::REFUND_FORFEIT,
578            'smsEnabled' => true,
579        ];
580    }
581
582    // =========================================================================
583    // PRIVATE HELPER METHODS
584    // =========================================================================
585
586    /**
587     * Calculate ccEvent dates from unified event dates and relative offsets
588     *
589     * @param Event $event The unified event
590     * @param array $config Configuration with relative day offsets
591     * @return array{earningStart: DateTime, earningEnd: DateTime, redemptionStart: DateTime, redemptionEnd: DateTime}
592     */
593    private function calculateCCEventDates(Event $event, array $config): array
594    {
595        // Earning period calculation
596        $earningStartRelative = $this->getConfigValue($config, 'earningStartRelative', 0);
597        $earningEndRelative = $this->getConfigValue($config, 'earningEndRelative', 0);
598
599        // Redemption period calculation
600        $redemptionStartRelative = $this->getConfigValue($config, 'redemptionStartRelative', 1);
601        $redemptionEndRelative = $this->getConfigValue($config, 'redemptionEndRelative', 14);
602
603        // Calculate earning dates relative to event start
604        $earningStart = $this->calculateDateFromRelative($event, $earningStartRelative);
605
606        // Calculate earning end relative to event end
607        $earningEnd = $this->calculateDateFromEndRelative($event, $earningEndRelative);
608
609        // Calculate redemption dates relative to event end
610        $redemptionStart = $this->calculateDateFromEndRelative($event, $redemptionStartRelative);
611        $redemptionEnd = $this->calculateDateFromEndRelative($event, $redemptionEndRelative);
612
613        return [
614            'earningStart' => $earningStart,
615            'earningEnd' => $earningEnd,
616            'redemptionStart' => $redemptionStart,
617            'redemptionEnd' => $redemptionEnd,
618        ];
619    }
620
621    /**
622     * Build ccEvent name from unified event
623     *
624     * @param Event $event The unified event
625     * @param array $config Configuration
626     * @return string ccEvent name
627     */
628    private function buildCCEventName(Event $event, array $config): string
629    {
630        $side = ucfirst($config['side'] ?? 'buy');
631        return "{$event->name} - Comeback Cash ({$side})";
632    }
633
634    /**
635     * Prepare earning configuration for database insert
636     *
637     * @param array $config Integration configuration
638     * @return array{type: string, tiers: string|null, flatAmount: float|null, percentage: float|null}
639     */
640    private function prepareEarningConfig(array $config): array
641    {
642        $earningType = $config['earningType'] ?? CCEvent::EARNING_FLAT;
643
644        return [
645            'type' => $earningType,
646            'tiers' => isset($config['earningTiers']) && !empty($config['earningTiers'])
647                ? json_encode($config['earningTiers'])
648                : null,
649            'flatAmount' => $earningType === CCEvent::EARNING_FLAT
650                ? ($config['couponValue'] ?? $config['earningValue'] ?? null)
651                : null,
652            'percentage' => $earningType === CCEvent::EARNING_PERCENTAGE
653                ? ($config['earningPercentage'] ?? null)
654                : null,
655        ];
656    }
657
658    /**
659     * Check for conflicts with existing ccEvents
660     *
661     * @param Event $event The unified event
662     * @param array $config Configuration
663     * @param int|null $excludeId ccEvent ID to exclude from conflict check
664     * @return array Conflict errors (empty if no conflicts)
665     */
666    private function checkForConflicts(Event $event, array $config, ?int $excludeId): array
667    {
668        $errors = [];
669
670        $dates = $this->calculateCCEventDates($event, $config);
671        $side = $config['side'] ?? CCEvent::SIDE_BUY;
672
673        // Check for overlapping active events on the same side
674        $sql = "SELECT id, name, start_date, end_date
675                FROM ccEvents
676                WHERE side = :side
677                AND status IN ('active', 'scheduled')
678                AND id != :exclude_id
679                AND (
680                    (start_date <= :end_date AND end_date >= :start_date)
681                    OR (start_date <= :end_date AND end_date IS NULL)
682                    OR (start_date IS NULL AND end_date >= :start_date)
683                )";
684
685        $stmt = $this->db->prepare($sql);
686        $stmt->execute([
687            ':side' => $side,
688            ':exclude_id' => $excludeId ?? 0,
689            ':start_date' => $this->formatDateForDb($dates['earningStart']),
690            ':end_date' => $this->formatDateForDb($dates['earningEnd']),
691        ]);
692
693        $conflicts = $stmt->fetchAll(PDO::FETCH_ASSOC);
694
695        if (!empty($conflicts)) {
696            foreach ($conflicts as $conflict) {
697                $errors[] = sprintf(
698                    'Date conflict with existing %s-side event "%s" (ID: %d)',
699                    $side,
700                    $conflict['name'],
701                    $conflict['id']
702                );
703            }
704        }
705
706        return $errors;
707    }
708
709    /**
710     * Get a ccEvent by ID
711     *
712     * @param int $id ccEvent ID
713     * @return CCEvent|null
714     */
715    private function getCCEvent(int $id): ?CCEvent
716    {
717        $sql = "SELECT * FROM ccEvents WHERE id = :id LIMIT 1";
718        $stmt = $this->db->prepare($sql);
719        $stmt->execute([':id' => $id]);
720        $row = $stmt->fetch(PDO::FETCH_ASSOC);
721
722        if (!$row) {
723            return null;
724        }
725
726        return CCEvent::fromRow($row);
727    }
728
729    /**
730     * Auto-end any active ccEvent on the specified side
731     *
732     * @param string $side 'buy' or 'sales'
733     * @param int $excludeId ccEvent ID to exclude
734     */
735    private function autoEndActiveEventOnSide(string $side, int $excludeId): void
736    {
737        $sql = "UPDATE ccEvents SET
738                status = 'ended',
739                end_date = COALESCE(end_date, NOW()),
740                updated_at = NOW()
741                WHERE side = :side
742                AND status = 'active'
743                AND id != :exclude_id";
744
745        $stmt = $this->db->prepare($sql);
746        $stmt->execute([
747            ':side' => $side,
748            ':exclude_id' => $excludeId,
749        ]);
750    }
751
752    /**
753     * Get coupon count for a ccEvent
754     *
755     * @param int $ccEventId
756     * @return int
757     */
758    private function getCouponCount(int $ccEventId): int
759    {
760        $sql = "SELECT COUNT(*) FROM ccCoupons WHERE event_id = :event_id";
761        $stmt = $this->db->prepare($sql);
762        $stmt->execute([':event_id' => $ccEventId]);
763        return (int) $stmt->fetchColumn();
764    }
765
766    /**
767     * Get coupon statistics for a ccEvent
768     *
769     * @param int $ccEventId
770     * @return array{issued: int, redeemed: int, active: int, expired: int, totalValueIssued: float, totalValueRedeemed: float}
771     */
772    private function getCouponStats(int $ccEventId): array
773    {
774        // Get basic counts
775        $sql = "SELECT
776                COUNT(*) as total,
777                SUM(CASE WHEN status = 'redeemed' THEN 1 ELSE 0 END) as redeemed,
778                SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active,
779                SUM(CASE WHEN status = 'expired' THEN 1 ELSE 0 END) as expired,
780                COALESCE(SUM(original_value), 0) as total_value_issued
781                FROM ccCoupons
782                WHERE event_id = :event_id";
783
784        $stmt = $this->db->prepare($sql);
785        $stmt->execute([':event_id' => $ccEventId]);
786        $row = $stmt->fetch(PDO::FETCH_ASSOC);
787
788        // Get total redeemed value
789        $sqlRedeemed = "SELECT COALESCE(SUM(r.redeemed_amount), 0) as total_redeemed
790                        FROM ccRedemptions r
791                        INNER JOIN ccCoupons c ON r.coupon_id = c.id
792                        WHERE c.event_id = :event_id";
793
794        $stmtRedeemed = $this->db->prepare($sqlRedeemed);
795        $stmtRedeemed->execute([':event_id' => $ccEventId]);
796        $redeemedRow = $stmtRedeemed->fetch(PDO::FETCH_ASSOC);
797
798        return [
799            'issued' => (int) ($row['total'] ?? 0),
800            'redeemed' => (int) ($row['redeemed'] ?? 0),
801            'active' => (int) ($row['active'] ?? 0),
802            'expired' => (int) ($row['expired'] ?? 0),
803            'totalValueIssued' => (float) ($row['total_value_issued'] ?? 0),
804            'totalValueRedeemed' => (float) ($redeemedRow['total_redeemed'] ?? 0),
805        ];
806    }
807}