Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.37% covered (success)
91.37%
127 / 139
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
RedemptionService
91.37% covered (success)
91.37%
127 / 139
66.67% covered (warning)
66.67%
4 / 6
26.43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 redeemCoupon
91.03% covered (success)
91.03%
71 / 78
0.00% covered (danger)
0.00%
0 / 1
12.10
 getRedemptionsByEvent
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 normalizeCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateCouponStatus
80.77% covered (warning)
80.77%
21 / 26
0.00% covered (danger)
0.00%
0 / 1
5.18
 insertRedemptionRecord
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace BuyerKiosk\ComebackCash\Services;
4
5use PDO;
6use PDOException;
7use DateTime;
8use BuyerKiosk\ComebackCash\Models\Coupon;
9use BuyerKiosk\ComebackCash\Models\Redemption;
10
11/**
12 * Redemption Service for Comeback Cash system
13 *
14 * Handles coupon redemption workflow per SDD lines 1050-1104:
15 * 1. Look up coupon by code (normalize: uppercase, remove hyphens)
16 * 2. Validate coupon status (must be 'active')
17 * 3. Validate expiration (expires_at > NOW())
18 * 4. Validate minimum purchase (transaction_amount >= event.redemption_min_purchase)
19 * 5. Begin transaction
20 * 6. Update coupon (reduce value, set status='redeemed' if fully used)
21 * 7. Insert redemption audit record
22 * 8. Commit transaction
23 * 9. Return success response
24 *
25 * Error Codes (per SDD lines 1098-1104):
26 * - INVALID_CODE (404): Coupon code not found
27 * - ALREADY_REDEEMED (409): Coupon already fully redeemed
28 * - EXPIRED (410): Coupon has expired
29 * - MIN_PURCHASE_NOT_MET (422): Transaction below minimum
30 *
31 * @package BuyerKiosk\ComebackCash\Services
32 */
33class RedemptionService
34{
35    private PDO $db;
36    private CouponService $couponService;
37
38    /**
39     * Constructor
40     *
41     * @param PDO $db Database connection
42     * @param CouponService $couponService Service for coupon operations
43     */
44    public function __construct(PDO $db, CouponService $couponService)
45    {
46        $this->db = $db;
47        $this->couponService = $couponService;
48    }
49
50    /**
51     * Redeem a coupon against a transaction
52     *
53     * Business Rules Applied:
54     * - Rule 9: Minimum purchase for redemption must be >= coupon value
55     * - Rule 12: Coupons are bearer instruments - no identity verification required
56     * - Rule 14: Minimum purchase threshold is pre-tax (merchandise subtotal only)
57     *
58     * @param string $code Coupon code to redeem
59     * @param string $transactionId POS transaction ID
60     * @param float $transactionAmount Transaction total (pre-tax merchandise subtotal)
61     * @param int $employeeId Employee processing the redemption
62     * @param string $redemptionMethod How coupon was presented (scan|manual|pos)
63     * @param float|null $amountToRedeem Optional partial redemption amount
64     * @return array Response with success status and redemption details
65     */
66    public function redeemCoupon(
67        string $code,
68        string $transactionId,
69        float $transactionAmount,
70        int $employeeId,
71        string $redemptionMethod,
72        ?float $amountToRedeem = null
73    ): array {
74        // Normalize code: trim whitespace, uppercase, remove hyphens
75        $normalizedCode = $this->normalizeCode($code);
76
77        // Validate redemption amount if provided
78        if ($amountToRedeem !== null) {
79            if ($amountToRedeem <= 0) {
80                return [
81                    'success' => false,
82                    'error' => 'Redemption amount must be greater than zero',
83                    'code' => 'INVALID_AMOUNT'
84                ];
85            }
86        }
87
88        // Look up coupon by code
89        $coupon = $this->couponService->findByCode($normalizedCode);
90
91        if ($coupon === null) {
92            return [
93                'success' => false,
94                'error' => 'Coupon code not found',
95                'code' => 'INVALID_CODE'
96            ];
97        }
98
99        // Validate coupon status
100        $statusValidation = $this->validateCouponStatus($coupon);
101        if ($statusValidation !== null) {
102            return $statusValidation;
103        }
104
105        // Validate expiration
106        if ($coupon->isExpired()) {
107            return [
108                'success' => false,
109                'error' => 'Coupon expired',
110                'code' => 'EXPIRED'
111            ];
112        }
113
114        // Get minimum purchase requirement from event
115        $minimumPurchase = $this->couponService->getMinimumPurchaseForEvent($coupon->getEventId());
116
117        // Validate minimum purchase
118        if ($transactionAmount < $minimumPurchase) {
119            return [
120                'success' => false,
121                'error' => "Minimum purchase of \${$minimumPurchase} required",
122                'code' => 'MIN_PURCHASE_NOT_MET',
123                'minimum_required' => $minimumPurchase
124            ];
125        }
126
127        // Determine redemption amount
128        $couponValue = $coupon->getValue();
129        $actualRedemptionAmount = $amountToRedeem ?? $couponValue;
130
131        // Cap at coupon value if amount exceeds it
132        if ($actualRedemptionAmount > $couponValue) {
133            $actualRedemptionAmount = $couponValue;
134        }
135
136        // Calculate remaining value after redemption
137        $remainingValue = round($couponValue - $actualRedemptionAmount, 2);
138
139        // Determine new status
140        // If remaining value is negligible (< $0.01), mark as fully redeemed
141        $newStatus = $remainingValue < 0.01
142            ? Coupon::STATUS_REDEEMED
143            : Coupon::STATUS_ACTIVE;
144
145        // Set remaining to exactly 0 if fully redeemed
146        if ($newStatus === Coupon::STATUS_REDEEMED) {
147            $remainingValue = 0.00;
148        }
149
150        // Execute redemption in atomic transaction
151        try {
152            $this->db->beginTransaction();
153
154            // Update coupon status and value
155            $this->couponService->updateCouponStatus(
156                $coupon->getId(),
157                $newStatus,
158                $remainingValue
159            );
160
161            // Insert redemption audit record
162            $this->insertRedemptionRecord(
163                $coupon->getId(),
164                $actualRedemptionAmount,
165                $transactionId,
166                $transactionAmount,
167                $employeeId,
168                $redemptionMethod
169            );
170
171            $this->db->commit();
172
173            return [
174                'success' => true,
175                'redeemed_amount' => $actualRedemptionAmount,
176                'remaining_value' => $remainingValue,
177                'coupon_status' => $newStatus,
178                'redemption_method' => $redemptionMethod
179            ];
180
181        } catch (PDOException $e) {
182            $this->db->rollBack();
183            return [
184                'success' => false,
185                'error' => 'Database error during redemption: ' . $e->getMessage(),
186                'code' => 'DATABASE_ERROR'
187            ];
188        } catch (\Exception $e) {
189            $this->db->rollBack();
190            return [
191                'success' => false,
192                'error' => 'Error during redemption: ' . $e->getMessage(),
193                'code' => 'REDEMPTION_ERROR'
194            ];
195        }
196    }
197
198    /**
199     * Get redemptions for a specific event with optional date filtering
200     *
201     * @param int $eventId The event ID to query redemptions for
202     * @param DateTime|null $startDate Optional start date filter
203     * @param DateTime|null $endDate Optional end date filter
204     * @return Redemption[] Array of Redemption objects
205     */
206    public function getRedemptionsByEvent(
207        int $eventId,
208        ?DateTime $startDate = null,
209        ?DateTime $endDate = null
210    ): array {
211        $sql = "
212            SELECT r.*, c.code as coupon_code, c.original_value, c.event_id
213            FROM ccRedemptions r
214            JOIN ccCoupons c ON r.coupon_id = c.id
215            WHERE c.event_id = :event_id
216        ";
217
218        $params = [':event_id' => $eventId];
219
220        if ($startDate !== null) {
221            $sql .= " AND r.redeemed_at >= :start_date";
222            $params[':start_date'] = $startDate->format('Y-m-d H:i:s');
223        }
224
225        if ($endDate !== null) {
226            $sql .= " AND r.redeemed_at <= :end_date";
227            $params[':end_date'] = $endDate->format('Y-m-d H:i:s');
228        }
229
230        $sql .= " ORDER BY r.redeemed_at DESC";
231
232        $stmt = $this->db->prepare($sql);
233        $stmt->execute($params);
234
235        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
236        $redemptions = [];
237
238        foreach ($rows as $row) {
239            $redemptions[] = Redemption::fromRow($row);
240        }
241
242        return $redemptions;
243    }
244
245    /**
246     * Normalize a coupon code for lookup
247     *
248     * - Trims whitespace
249     * - Converts to uppercase
250     * - Removes hyphens (display format is XXXX-XXXX)
251     *
252     * @param string $code Raw coupon code input
253     * @return string Normalized code
254     */
255    private function normalizeCode(string $code): string
256    {
257        return strtoupper(str_replace('-', '', trim($code)));
258    }
259
260    /**
261     * Validate coupon status for redemption eligibility
262     *
263     * @param Coupon $coupon The coupon to validate
264     * @return array|null Error response if invalid, null if valid
265     */
266    private function validateCouponStatus(Coupon $coupon): ?array
267    {
268        $status = $coupon->getStatus();
269
270        if ($status === Coupon::STATUS_REDEEMED) {
271            return [
272                'success' => false,
273                'error' => 'Coupon already redeemed',
274                'code' => 'ALREADY_REDEEMED'
275            ];
276        }
277
278        if ($status === Coupon::STATUS_EXPIRED) {
279            return [
280                'success' => false,
281                'error' => 'Coupon expired',
282                'code' => 'EXPIRED'
283            ];
284        }
285
286        if ($status === Coupon::STATUS_VOIDED) {
287            return [
288                'success' => false,
289                'error' => 'Coupon has been voided',
290                'code' => 'ALREADY_REDEEMED'
291            ];
292        }
293
294        if ($status !== Coupon::STATUS_ACTIVE) {
295            return [
296                'success' => false,
297                'error' => 'Coupon is not active',
298                'code' => 'INVALID_CODE'
299            ];
300        }
301
302        return null;
303    }
304
305    /**
306     * Insert a redemption audit record
307     *
308     * @param int $couponId Coupon being redeemed
309     * @param float $redeemedAmount Amount being redeemed
310     * @param string $transactionId POS transaction ID
311     * @param float $transactionAmount Transaction total
312     * @param int $employeeId Employee processing redemption
313     * @param string $redemptionMethod How coupon was presented
314     * @return int The inserted redemption ID
315     * @throws PDOException If insert fails
316     */
317    private function insertRedemptionRecord(
318        int $couponId,
319        float $redeemedAmount,
320        string $transactionId,
321        float $transactionAmount,
322        int $employeeId,
323        string $redemptionMethod
324    ): int {
325        $sql = "
326            INSERT INTO ccRedemptions (
327                coupon_id,
328                redeemed_amount,
329                transaction_id,
330                transaction_amount,
331                redeemed_by_employee_id,
332                redeemed_at,
333                redemption_method
334            ) VALUES (
335                :coupon_id,
336                :redeemed_amount,
337                :transaction_id,
338                :transaction_amount,
339                :redeemed_by_employee_id,
340                NOW(),
341                :redemption_method
342            )
343        ";
344
345        $stmt = $this->db->prepare($sql);
346
347        if ($stmt === false) {
348            throw new PDOException('Failed to prepare redemption insert statement');
349        }
350
351        $stmt->bindValue(':coupon_id', $couponId, PDO::PARAM_INT);
352        $stmt->bindValue(':redeemed_amount', $redeemedAmount);
353        $stmt->bindValue(':transaction_id', $transactionId);
354        $stmt->bindValue(':transaction_amount', $transactionAmount);
355        $stmt->bindValue(':redeemed_by_employee_id', $employeeId, PDO::PARAM_INT);
356        $stmt->bindValue(':redemption_method', $redemptionMethod);
357
358        $result = $stmt->execute();
359
360        if (!$result) {
361            throw new PDOException('Failed to insert redemption record');
362        }
363
364        return (int) $this->db->lastInsertId();
365    }
366}