Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 210
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
CouponService
0.00% covered (danger)
0.00%
0 / 210
0.00% covered (danger)
0.00%
0 / 14
1482
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 issueCoupon
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
56
 validateCoupon
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
72
 getCouponByCode
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 findCouponByTransactionId
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 insertCoupon
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 shouldQueueSms
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 normalizeCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formatDisplayCode
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 formatCouponResponse
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 formatCouponFromRow
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 findByCode
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getMinimumPurchaseForEvent
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 updateCouponStatus
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\ComebackCash\Services;
4
5use PDO;
6use DateTime;
7use BuyerKiosk\ComebackCash\Models\Coupon;
8use BuyerKiosk\ComebackCash\Models\Event;
9
10/**
11 * CouponService - Handles coupon issuance, validation, and lookup
12 *
13 * This service implements the coupon issuance flow per SDD lines 989-1026:
14 * 1. Customer completes transaction at POS
15 * 2. POS sends transaction details to Comeback Cash API
16 * 3. System checks for active event on matching side (buy/sales)
17 * 4. System calculates reward based on event configuration
18 * 5. If qualified, system generates coupon and returns details
19 * 6. If customer phone provided, SMS notification is queued
20 *
21 * Business Rules:
22 * - Rule 16: Buy-side has NO earning thresholds - any completed buy earns flat coupon
23 * - Rule 15: Sales-side thresholds on net amount AFTER discounts
24 * - Rule 12: Coupons are bearer instruments - anyone with code can redeem
25 *
26 * @package BuyerKiosk\ComebackCash\Services
27 */
28class CouponService
29{
30    /**
31     * Default number of days coupons are valid
32     */
33    private const DEFAULT_DAYS_VALID = 30;
34
35    /**
36     * @var PDO Database connection
37     */
38    private PDO $db;
39
40    /**
41     * @var EventService Event service for finding active events
42     */
43    private EventService $eventService;
44
45    /**
46     * @var SmsQueueInterface|null SMS queue for notifications
47     */
48    private ?SmsQueueInterface $smsQueue;
49
50    /**
51     * Create a new CouponService instance
52     *
53     * @param PDO $db Database connection
54     * @param EventService $eventService Event service for finding active events
55     * @param SmsQueueInterface|null $smsQueue Optional SMS queue for notifications
56     */
57    public function __construct(PDO $db, EventService $eventService, ?SmsQueueInterface $smsQueue = null)
58    {
59        $this->db = $db;
60        $this->eventService = $eventService;
61        $this->smsQueue = $smsQueue;
62    }
63
64    /**
65     * Issue a coupon for a qualifying transaction
66     *
67     * This method is idempotent - duplicate transaction_ids return the existing coupon.
68     *
69     * @param string $side Transaction side: 'buy' or 'sales'
70     * @param string $transactionId Unique transaction identifier
71     * @param float $transactionAmount Transaction amount (net, after discounts)
72     * @param string|null $customerPhone Customer phone for SMS notification
73     * @param string|null $customerName Customer name for personalization
74     * @param int|null $employeeId Employee who processed the transaction
75     * @return array Result array with success, coupon data, or error info
76     */
77    public function issueCoupon(
78        string $side,
79        string $transactionId,
80        float $transactionAmount,
81        ?string $customerPhone = null,
82        ?string $customerName = null,
83        ?int $employeeId = null
84    ): array {
85        // Validate side parameter
86        if (!in_array($side, [Event::SIDE_BUY, Event::SIDE_SALES], true)) {
87            return [
88                'success' => false,
89                'error' => 'Invalid transaction side',
90                'code' => 'INVALID_SIDE',
91            ];
92        }
93
94        // Reject negative transaction amounts
95        if ($transactionAmount < 0) {
96            return [
97                'success' => false,
98                'error' => 'Transaction amount cannot be negative',
99                'code' => 'INVALID_AMOUNT',
100            ];
101        }
102
103        // Check for active event on this side
104        $event = $this->eventService->getActiveEventBySide($side);
105        if ($event === null) {
106            return [
107                'success' => false,
108                'error' => "No active event for {$side} side",
109                'code' => 'NO_ACTIVE_EVENT',
110            ];
111        }
112
113        // Calculate reward based on event configuration FIRST
114        // This allows early return for non-qualifying transactions without DB queries
115        $rewardAmount = $event->calculateReward($transactionAmount);
116
117        // For sales-side, null reward means below threshold - return early
118        if ($rewardAmount === null) {
119            return [
120                'success' => true,
121                'coupon' => null,
122                'reason' => 'Amount below minimum threshold',
123            ];
124        }
125
126        // Check for duplicate transaction (idempotency) - only for qualifying transactions
127        $existingCoupon = $this->findCouponByTransactionId($transactionId);
128        if ($existingCoupon !== null) {
129            // Return existing coupon without queueing another SMS
130            return [
131                'success' => true,
132                'coupon' => $this->formatCouponResponse($existingCoupon, $event),
133            ];
134        }
135
136        // Generate unique coupon code
137        $code = Coupon::generateCode();
138
139        // Calculate expiration date
140        $daysValid = $event->redemptionDaysValid ?? self::DEFAULT_DAYS_VALID;
141        $expiresAt = new DateTime("+{$daysValid} days");
142
143        // Insert the coupon
144        $couponId = $this->insertCoupon(
145            eventId: $event->id,
146            code: $code,
147            value: $rewardAmount,
148            transactionId: $transactionId,
149            transactionAmount: $transactionAmount,
150            customerPhone: $customerPhone,
151            customerName: $customerName,
152            expiresAt: $expiresAt,
153            employeeId: $employeeId
154        );
155
156        // Build coupon response
157        $couponData = [
158            'id' => $couponId,
159            'code' => $code,
160            'value' => $rewardAmount,
161            'expires_at' => $expiresAt->format('Y-m-d H:i:s'),
162            'event_name' => $event->name,
163            'display_code' => $this->formatDisplayCode($code),
164        ];
165
166        // Queue SMS notification if appropriate
167        if ($this->shouldQueueSms($customerPhone, $event)) {
168            $this->smsQueue->queueCouponNotification($customerPhone, $couponData, $event);
169        }
170
171        return [
172            'success' => true,
173            'coupon' => $couponData,
174        ];
175    }
176
177    /**
178     * Validate a coupon code
179     *
180     * Checks if the coupon exists and is currently redeemable.
181     *
182     * @param string $code Coupon code (case-insensitive, hyphens ignored)
183     * @return array Validation result with valid flag, status, and optional error
184     */
185    public function validateCoupon(string $code): array
186    {
187        $normalizedCode = $this->normalizeCode($code);
188
189        $sql = "SELECT c.*, e.name as event_name, e.redemption_min_purchase
190                FROM ccCoupons c
191                JOIN ccEvents e ON c.event_id = e.id
192                WHERE c.code = ?";
193
194        $stmt = $this->db->prepare($sql);
195        $stmt->execute([$normalizedCode]);
196        $row = $stmt->fetch(PDO::FETCH_ASSOC);
197
198        if ($row === false) {
199            return [
200                'valid' => false,
201                'status' => 'not_found',
202                'reason' => 'Coupon not found',
203            ];
204        }
205
206        $coupon = Coupon::fromRow($row);
207
208        // Check if expired by date (even if status is still 'active')
209        if ($coupon->isExpired()) {
210            return [
211                'valid' => false,
212                'status' => 'expired',
213                'reason' => 'Coupon has expired',
214                'coupon' => $this->formatCouponFromRow($row),
215            ];
216        }
217
218        // Check status
219        switch ($coupon->getStatus()) {
220            case Coupon::STATUS_ACTIVE:
221                return [
222                    'valid' => true,
223                    'status' => 'active',
224                    'coupon' => $this->formatCouponFromRow($row),
225                ];
226
227            case Coupon::STATUS_REDEEMED:
228                return [
229                    'valid' => false,
230                    'status' => 'redeemed',
231                    'reason' => 'Coupon has already been redeemed',
232                    'coupon' => $this->formatCouponFromRow($row),
233                ];
234
235            case Coupon::STATUS_EXPIRED:
236                return [
237                    'valid' => false,
238                    'status' => 'expired',
239                    'reason' => 'Coupon has expired',
240                    'coupon' => $this->formatCouponFromRow($row),
241                ];
242
243            case Coupon::STATUS_VOIDED:
244                return [
245                    'valid' => false,
246                    'status' => 'voided',
247                    'reason' => 'Coupon has been voided',
248                    'coupon' => $this->formatCouponFromRow($row),
249                ];
250
251            default:
252                return [
253                    'valid' => false,
254                    'status' => $coupon->getStatus(),
255                    'reason' => 'Coupon is not redeemable',
256                    'coupon' => $this->formatCouponFromRow($row),
257                ];
258        }
259    }
260
261    /**
262     * Get coupon by code with event details
263     *
264     * @param string $code Coupon code (case-insensitive, hyphens ignored)
265     * @return array|null Coupon and event data, or null if not found
266     */
267    public function getCouponByCode(string $code): ?array
268    {
269        $normalizedCode = $this->normalizeCode($code);
270
271        $sql = "SELECT c.*,
272                       e.name as event_name,
273                       e.side as event_side,
274                       e.redemption_min_purchase
275                FROM ccCoupons c
276                JOIN ccEvents e ON c.event_id = e.id
277                WHERE c.code = ?";
278
279        $stmt = $this->db->prepare($sql);
280        $stmt->execute([$normalizedCode]);
281        $row = $stmt->fetch(PDO::FETCH_ASSOC);
282
283        if ($row === false) {
284            return null;
285        }
286
287        return [
288            'coupon' => $this->formatCouponFromRow($row),
289            'event' => [
290                'id' => (int) $row['event_id'],
291                'name' => $row['event_name'],
292                'side' => $row['event_side'] ?? null,
293                'redemption_min_purchase' => isset($row['redemption_min_purchase'])
294                    ? (float) $row['redemption_min_purchase']
295                    : null,
296            ],
297        ];
298    }
299
300    /**
301     * Find an existing coupon by source transaction ID
302     *
303     * @param string $transactionId The source transaction ID
304     * @return array|null Coupon row data or null if not found
305     */
306    private function findCouponByTransactionId(string $transactionId): ?array
307    {
308        $sql = "SELECT * FROM ccCoupons WHERE source_transaction_id = ?";
309        $stmt = $this->db->prepare($sql);
310        $stmt->execute([$transactionId]);
311        $row = $stmt->fetch(PDO::FETCH_ASSOC);
312
313        return $row !== false ? $row : null;
314    }
315
316    /**
317     * Insert a new coupon record
318     *
319     * @param int $eventId Event ID
320     * @param string $code Coupon code
321     * @param float $value Coupon value
322     * @param string $transactionId Source transaction ID
323     * @param float $transactionAmount Source transaction amount
324     * @param string|null $customerPhone Customer phone
325     * @param string|null $customerName Customer name
326     * @param DateTime $expiresAt Expiration date
327     * @param int|null $employeeId Issuing employee ID
328     * @return int The inserted coupon ID
329     */
330    private function insertCoupon(
331        int $eventId,
332        string $code,
333        float $value,
334        string $transactionId,
335        float $transactionAmount,
336        ?string $customerPhone,
337        ?string $customerName,
338        DateTime $expiresAt,
339        ?int $employeeId
340    ): int {
341        $sql = "INSERT INTO ccCoupons (
342                    event_id,
343                    code,
344                    value,
345                    original_value,
346                    source_transaction_id,
347                    source_transaction_amount,
348                    customer_phone,
349                    customer_name,
350                    status,
351                    expires_at,
352                    issued_at,
353                    issued_by_employee_id
354                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?)";
355
356        $stmt = $this->db->prepare($sql);
357        $stmt->execute([
358            $eventId,
359            $code,
360            $value,
361            $value, // original_value same as value at issuance
362            $transactionId,
363            $transactionAmount,
364            $customerPhone,
365            $customerName,
366            Coupon::STATUS_ACTIVE,
367            $expiresAt->format('Y-m-d H:i:s'),
368            $employeeId,
369        ]);
370
371        return (int) $this->db->lastInsertId();
372    }
373
374    /**
375     * Determine if SMS should be queued
376     *
377     * SMS is queued only when:
378     * 1. Phone number is provided and not empty
379     * 2. Event has SMS enabled
380     * 3. SMS queue service is available
381     *
382     * @param string|null $customerPhone Customer phone number
383     * @param Event $event The event configuration
384     * @return bool Whether to queue SMS
385     */
386    private function shouldQueueSms(?string $customerPhone, Event $event): bool
387    {
388        return $customerPhone !== null
389            && $customerPhone !== ''
390            && $event->smsEnabled
391            && $this->smsQueue !== null;
392    }
393
394    /**
395     * Normalize a coupon code for lookup
396     *
397     * Converts to uppercase and removes hyphens for consistent matching.
398     *
399     * @param string $code Raw coupon code
400     * @return string Normalized code
401     */
402    private function normalizeCode(string $code): string
403    {
404        return strtoupper(str_replace('-', '', $code));
405    }
406
407    /**
408     * Format code for display (XXXX-XXXX)
409     *
410     * @param string $code 8-character code
411     * @return string Formatted code with hyphen
412     */
413    private function formatDisplayCode(string $code): string
414    {
415        if (strlen($code) === 8) {
416            return substr($code, 0, 4) . '-' . substr($code, 4, 4);
417        }
418        return $code;
419    }
420
421    /**
422     * Format coupon response from existing coupon row and event
423     *
424     * @param array $couponRow Database row
425     * @param Event $event Event model
426     * @return array Formatted coupon data
427     */
428    private function formatCouponResponse(array $couponRow, Event $event): array
429    {
430        return [
431            'id' => (int) $couponRow['id'],
432            'code' => $couponRow['code'],
433            'value' => (float) $couponRow['value'],
434            'expires_at' => $couponRow['expires_at'],
435            'event_name' => $event->name,
436            'display_code' => $this->formatDisplayCode($couponRow['code']),
437        ];
438    }
439
440    /**
441     * Format coupon data from database row
442     *
443     * @param array $row Database row
444     * @return array Formatted coupon data
445     */
446    private function formatCouponFromRow(array $row): array
447    {
448        return [
449            'id' => (int) $row['id'],
450            'code' => $row['code'],
451            'value' => (float) $row['value'],
452            'original_value' => (float) $row['original_value'],
453            'status' => $row['status'],
454            'expires_at' => $row['expires_at'],
455            'issued_at' => $row['issued_at'],
456            'source_transaction_id' => $row['source_transaction_id'],
457            'source_transaction_amount' => (float) $row['source_transaction_amount'],
458            'customer_phone' => $row['customer_phone'] ?? null,
459            'customer_name' => $row['customer_name'] ?? null,
460            'display_code' => $this->formatDisplayCode($row['code']),
461            'event_name' => $row['event_name'] ?? null,
462            'redemption_min_purchase' => isset($row['redemption_min_purchase'])
463                ? (float) $row['redemption_min_purchase']
464                : null,
465        ];
466    }
467
468    /**
469     * Find a coupon by its code
470     *
471     * Used by RedemptionService for coupon lookup during redemption.
472     *
473     * @param string $code Coupon code (case-insensitive, hyphens ignored)
474     * @return Coupon|null The Coupon model or null if not found
475     */
476    public function findByCode(string $code): ?Coupon
477    {
478        $normalizedCode = $this->normalizeCode($code);
479
480        $sql = "SELECT c.*, e.name as event_name, e.redemption_min_purchase
481                FROM ccCoupons c
482                JOIN ccEvents e ON c.event_id = e.id
483                WHERE c.code = ?";
484
485        $stmt = $this->db->prepare($sql);
486        $stmt->execute([$normalizedCode]);
487        $row = $stmt->fetch(PDO::FETCH_ASSOC);
488
489        if ($row === false) {
490            return null;
491        }
492
493        return Coupon::fromRow($row);
494    }
495
496    /**
497     * Get the minimum purchase requirement for an event
498     *
499     * Used by RedemptionService to validate transaction meets minimum.
500     *
501     * @param int $eventId The event ID
502     * @return float The minimum purchase amount (0 if not set)
503     */
504    public function getMinimumPurchaseForEvent(int $eventId): float
505    {
506        $sql = "SELECT redemption_min_purchase FROM ccEvents WHERE id = ?";
507        $stmt = $this->db->prepare($sql);
508        $stmt->execute([$eventId]);
509        $row = $stmt->fetch(PDO::FETCH_ASSOC);
510
511        if ($row === false || $row['redemption_min_purchase'] === null) {
512            return 0.0;
513        }
514
515        return (float) $row['redemption_min_purchase'];
516    }
517
518    /**
519     * Update coupon status and value after redemption
520     *
521     * Used by RedemptionService during the redemption transaction.
522     *
523     * @param int $couponId The coupon ID to update
524     * @param string $status New status (active, redeemed, expired, voided)
525     * @param float $remainingValue New remaining value
526     * @return bool True if update was successful
527     */
528    public function updateCouponStatus(int $couponId, string $status, float $remainingValue): bool
529    {
530        $sql = "UPDATE ccCoupons
531                SET value = ?, status = ?, updated_at = NOW()
532                WHERE id = ?";
533
534        $stmt = $this->db->prepare($sql);
535        return $stmt->execute([$remainingValue, $status, $couponId]);
536    }
537}