Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 281
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComebackCashApiController
0.00% covered (danger)
0.00%
0 / 281
0.00% covered (danger)
0.00%
0 / 16
6480
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
 checkAuth
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 getEvents
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 createEvent
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
90
 updateEvent
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
132
 deleteEvent
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 lookupCoupon
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
30
 redeemCoupon
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
72
 getEventReport
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 getEventStatistics
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
2
 setJsonContentType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sendJsonResponse
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sendErrorResponse
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getJsonRequestBody
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 validateRedemptionRequest
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
132
 mapRedemptionErrorToHttpStatus
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3namespace BuyerKiosk\ComebackCash\Controllers;
4
5use Exception;
6use BuyerKiosk\ComebackCash\ComebackCashFactory;
7use BuyerKiosk\ComebackCash\Services\EventService;
8use BuyerKiosk\ComebackCash\Services\CouponService;
9use BuyerKiosk\ComebackCash\Services\RedemptionService;
10use BuyerKiosk\ComebackCash\Services\ComebackCashAbly;
11use BuyerKiosk\ComebackCash\Models\Event;
12use BuyerKiosk\ComebackCash\Models\Redemption;
13
14/**
15 * ComebackCashApiController - REST API for internal Workspace operations
16 *
17 * Provides endpoints for workspace users to manage Comeback Cash events and process redemptions:
18 *
19 * Event Management:
20 * 1. GET /{typeNum}/api/comeback-cash/events - List events with filtering
21 * 2. POST /{typeNum}/api/comeback-cash/events - Create event
22 * 3. PUT /{typeNum}/api/comeback-cash/events/{id} - Update event
23 * 4. DELETE /{typeNum}/api/comeback-cash/events/{id} - Delete event (draft only)
24 *
25 * Coupon Operations:
26 * 5. GET /{typeNum}/api/comeback-cash/lookup?code={code} - Lookup coupon for redemption
27 * 6. POST /{typeNum}/api/comeback-cash/redeem - Redeem coupon (workspace)
28 *
29 * Reporting:
30 * 7. GET /{typeNum}/api/comeback-cash/events/{id}/report - Get event statistics
31 *
32 * Authentication: All endpoints require SESSION authentication + uri_comeback_cash permission
33 *
34 * @see SDD Section 6.2.2 - Workspace API Specification
35 * @package BuyerKiosk\ComebackCash\Controllers
36 */
37class ComebackCashApiController
38{
39    /**
40     * @var \Slim\Slim Slim application instance
41     */
42    private $app;
43
44    /**
45     * @var \Store Store object
46     */
47    private $store;
48
49    /**
50     * @var ComebackCashFactory Factory for creating services
51     */
52    private ComebackCashFactory $factory;
53
54    /**
55     * Constructor
56     *
57     * @param \Slim\Slim $app Slim application instance
58     * @param \Store $store Store object (validated)
59     */
60    public function __construct($app, \Store $store)
61    {
62        $this->app = $app;
63        $this->store = $store;
64        $this->factory = new ComebackCashFactory($store->getTypeNum());
65    }
66
67    // =========================================================================
68    // AUTHENTICATION & PERMISSION CHECK
69    // =========================================================================
70
71    /**
72     * Check session authentication and comeback cash permission
73     *
74     * Allows access if user has either:
75     * - uri_comeback_cash (workspace employees)
76     * - uri_store_settings (admin/managers)
77     *
78     * @return bool True if authorized, false otherwise (response already sent)
79     */
80    private function checkAuth(): bool
81    {
82        // Check if user is authenticated (session-based)
83        if (!isset($this->app->user) || !$this->app->user) {
84            $this->sendErrorResponse('Authentication required', 401, 'UNAUTHORIZED');
85            return false;
86        }
87
88        // Check permission - allow either uri_comeback_cash or uri_store_settings
89        if (!$this->app->user->checkAccess('uri_comeback_cash') && !$this->app->user->checkAccess('uri_store_settings')) {
90            $this->sendErrorResponse('Access denied. Requires uri_comeback_cash or uri_store_settings permission', 403, 'FORBIDDEN');
91            return false;
92        }
93
94        return true;
95    }
96
97    // =========================================================================
98    // EVENT MANAGEMENT ENDPOINTS
99    // =========================================================================
100
101    /**
102     * GET /{typeNum}/api/comeback-cash/events
103     *
104     * List events with optional filtering by side and status.
105     *
106     * Query Parameters:
107     * - side: 'buy' or 'sales' (optional)
108     * - status: event status filter (optional)
109     *
110     * Response:
111     * {
112     *   "success": true,
113     *   "events": Event[],
114     *   "total": int
115     * }
116     */
117    public function getEvents(): void
118    {
119        $this->setJsonContentType();
120
121        if (!$this->checkAuth()) {
122            return;
123        }
124
125        try {
126            $eventService = $this->factory->createEventService();
127
128            // Build filters from query parameters
129            $filters = [];
130            $side = $this->app->request->get('side');
131            $status = $this->app->request->get('status');
132
133            if (!empty($side)) {
134                $filters['side'] = $side;
135            }
136            if (!empty($status)) {
137                $filters['status'] = $status;
138            }
139
140            // Get events from service
141            $events = $eventService->listEvents($filters);
142
143            // Convert events to array format
144            $eventsArray = array_map(fn(Event $e) => $e->toArray(), $events);
145
146            $response = [
147                'success' => true,
148                'events' => $eventsArray,
149                'total' => count($eventsArray),
150            ];
151
152            $this->sendJsonResponse($response);
153
154        } catch (Exception $e) {
155            error_log("ComebackCashApiController::getEvents error: " . $e->getMessage());
156            $this->sendErrorResponse('Failed to retrieve events', 500);
157        }
158    }
159
160    /**
161     * POST /{typeNum}/api/comeback-cash/events
162     *
163     * Create a new Comeback Cash event.
164     *
165     * Request Body: Event properties (name, side, status, etc.)
166     *
167     * Response:
168     * {
169     *   "success": true,
170     *   "event": Event
171     * }
172     */
173    public function createEvent(): void
174    {
175        $this->setJsonContentType();
176
177        if (!$this->checkAuth()) {
178            return;
179        }
180
181        try {
182            // Parse request body
183            $data = $this->getJsonRequestBody();
184            if ($data === null) {
185                $this->sendErrorResponse('Invalid JSON in request body', 400, 'VALIDATION_ERROR');
186                return;
187            }
188
189            // Validate required fields
190            if (empty($data['name'])) {
191                $this->sendErrorResponse('Event name is required', 400, 'VALIDATION_ERROR');
192                return;
193            }
194
195            if (empty($data['side']) || !in_array($data['side'], [Event::SIDE_BUY, Event::SIDE_SALES], true)) {
196                $this->sendErrorResponse('Valid side (buy or sales) is required', 400, 'VALIDATION_ERROR');
197                return;
198            }
199
200            // Add creator info from session
201            $data['createdBy'] = $this->app->user->id ?? 0;
202
203            $eventService = $this->factory->createEventService();
204
205            // Create the event
206            $result = $eventService->createEvent($data);
207            $event = $eventService->getEvent($result['id']);
208
209            $response = [
210                'success' => true,
211                'event' => $event ? $event->toArray() : null,
212            ];
213
214            $this->app->response->setStatus(201);
215            $this->sendJsonResponse($response);
216
217        } catch (\InvalidArgumentException $e) {
218            $this->sendErrorResponse($e->getMessage(), 400, 'VALIDATION_ERROR');
219        } catch (Exception $e) {
220            error_log("ComebackCashApiController::createEvent error: " . $e->getMessage());
221            $this->sendErrorResponse('Failed to create event', 500);
222        }
223    }
224
225    /**
226     * PUT /{typeNum}/api/comeback-cash/events/{id}
227     *
228     * Update an existing event. Triggers Ably notification if status changes.
229     *
230     * @param int $id Event ID
231     *
232     * Response:
233     * {
234     *   "success": true,
235     *   "event": Event
236     * }
237     */
238    public function updateEvent(int $id): void
239    {
240        $this->setJsonContentType();
241
242        if (!$this->checkAuth()) {
243            return;
244        }
245
246        try {
247            // Parse request body
248            $data = $this->getJsonRequestBody();
249            if ($data === null) {
250                $this->sendErrorResponse('Invalid JSON in request body', 400, 'VALIDATION_ERROR');
251                return;
252            }
253
254            $eventService = $this->factory->createEventService();
255
256            // Check if status is being changed (for Ably notification)
257            $existingEvent = $eventService->getEvent($id);
258            if (!$existingEvent) {
259                $this->sendErrorResponse('Event not found', 404, 'NOT_FOUND');
260                return;
261            }
262
263            $statusChanging = isset($data['status']) && $data['status'] !== $existingEvent->status;
264
265            // Update the event
266            $result = $eventService->updateEvent($id, $data);
267
268            // Trigger Ably notification if status changed
269            if ($statusChanging && $result['success']) {
270                try {
271                    $ablyService = $this->factory->getAblyService();
272                    $ablyService->broadcastSettingsUpdate();
273                } catch (Exception $e) {
274                    // Log but don't fail on Ably error
275                    error_log("ComebackCashApiController::updateEvent Ably error: " . $e->getMessage());
276                }
277            }
278
279            $response = [
280                'success' => true,
281                'event' => $result['event'] ? $result['event']->toArray() : null,
282            ];
283
284            $this->sendJsonResponse($response);
285
286        } catch (\InvalidArgumentException $e) {
287            $this->sendErrorResponse($e->getMessage(), 400, 'VALIDATION_ERROR');
288        } catch (Exception $e) {
289            error_log("ComebackCashApiController::updateEvent error: " . $e->getMessage());
290            $this->sendErrorResponse('Failed to update event', 500);
291        }
292    }
293
294    /**
295     * DELETE /{typeNum}/api/comeback-cash/events/{id}
296     *
297     * Delete an event. Only draft events can be deleted.
298     *
299     * @param int $id Event ID
300     *
301     * Response:
302     * {
303     *   "success": true
304     * }
305     */
306    public function deleteEvent(int $id): void
307    {
308        $this->setJsonContentType();
309
310        if (!$this->checkAuth()) {
311            return;
312        }
313
314        try {
315            $eventService = $this->factory->createEventService();
316            $deleted = $eventService->deleteEvent($id);
317
318            $response = [
319                'success' => $deleted,
320            ];
321
322            $this->sendJsonResponse($response);
323
324        } catch (\InvalidArgumentException $e) {
325            $this->sendErrorResponse($e->getMessage(), 400, 'VALIDATION_ERROR');
326        } catch (Exception $e) {
327            error_log("ComebackCashApiController::deleteEvent error: " . $e->getMessage());
328            $this->sendErrorResponse('Failed to delete event', 500);
329        }
330    }
331
332    // =========================================================================
333    // COUPON OPERATIONS ENDPOINTS
334    // =========================================================================
335
336    /**
337     * GET /{typeNum}/api/comeback-cash/lookup?code={code}
338     *
339     * Lookup and validate a coupon code for redemption.
340     *
341     * Query Parameters:
342     * - code: The coupon code to validate (required)
343     *
344     * Response:
345     * {
346     *   "success": true,
347     *   "coupon": { code, value, status, expires_at, min_purchase } | null,
348     *   "redeemable": bool,
349     *   "reason": string | null
350     * }
351     */
352    public function lookupCoupon(): void
353    {
354        $this->setJsonContentType();
355
356        if (!$this->checkAuth()) {
357            return;
358        }
359
360        try {
361            $code = $this->app->request->get('code');
362
363            // Validate code format
364            if (empty(trim($code ?? ''))) {
365                $this->sendErrorResponse('Coupon code is required', 400, 'VALIDATION_ERROR');
366                return;
367            }
368
369            $couponService = $this->factory->createCouponService();
370            $result = $couponService->validateCoupon($code);
371
372            // Transform for workspace lookup response
373            if ($result['valid'] ?? false) {
374                $response = [
375                    'success' => true,
376                    'coupon' => [
377                        'code' => $result['coupon']['code'] ?? $code,
378                        'value' => $result['coupon']['value'] ?? $result['value'] ?? 0,
379                        'status' => $result['coupon']['status'] ?? $result['status'] ?? 'unknown',
380                        'expires_at' => $result['coupon']['expires_at'] ?? $result['expires_at'] ?? null,
381                        'min_purchase' => $result['coupon']['redemption_min_purchase'] ?? $result['min_purchase'] ?? null,
382                    ],
383                    'redeemable' => true,
384                    'reason' => null,
385                ];
386            } else {
387                $response = [
388                    'success' => true,
389                    'coupon' => null,
390                    'redeemable' => false,
391                    'reason' => $result['reason'] ?? 'Invalid coupon code',
392                ];
393            }
394
395            $this->sendJsonResponse($response);
396
397        } catch (Exception $e) {
398            error_log("ComebackCashApiController::lookupCoupon error: " . $e->getMessage());
399            $this->sendErrorResponse('Failed to validate coupon', 500);
400        }
401    }
402
403    /**
404     * POST /{typeNum}/api/comeback-cash/redeem
405     *
406     * Process a coupon redemption from the workspace.
407     * Uses METHOD_MANUAL and actual employee ID from session.
408     *
409     * Request Body:
410     * - code: string (required)
411     * - transaction_id: string (required)
412     * - transaction_amount: decimal (required)
413     * - amount_to_redeem: decimal (optional)
414     *
415     * Response:
416     * {
417     *   "success": true,
418     *   "redeemed_amount": decimal,
419     *   "remaining_value": decimal,
420     *   "coupon_status": string,
421     *   "redemption_method": "manual"
422     * }
423     */
424    public function redeemCoupon(): void
425    {
426        $this->setJsonContentType();
427
428        if (!$this->checkAuth()) {
429            return;
430        }
431
432        try {
433            // Parse request body
434            $data = $this->getJsonRequestBody();
435            if ($data === null) {
436                $this->sendErrorResponse('Invalid JSON in request body', 400, 'VALIDATION_ERROR');
437                return;
438            }
439
440            // Validate required fields
441            $validation = $this->validateRedemptionRequest($data);
442            if ($validation !== null) {
443                $this->sendErrorResponse($validation['error'], $validation['status'], 'VALIDATION_ERROR');
444                return;
445            }
446
447            // Extract parameters
448            $code = trim($data['code']);
449            $transactionId = substr(trim($data['transaction_id']), 0, 50);
450            $transactionAmount = (float) $data['transaction_amount'];
451            $amountToRedeem = isset($data['amount_to_redeem']) ? (float) $data['amount_to_redeem'] : null;
452
453            // Get employee ID from session (KEY DIFFERENCE from POS API)
454            $employeeId = $this->app->user->id ?? 0;
455
456            // Process redemption via service
457            // KEY DIFFERENCE: Workspace uses METHOD_MANUAL (not METHOD_POS)
458            $redemptionService = $this->factory->createRedemptionService();
459            $result = $redemptionService->redeemCoupon(
460                $code,
461                $transactionId,
462                $transactionAmount,
463                $employeeId,
464                Redemption::METHOD_MANUAL,
465                $amountToRedeem
466            );
467
468            // Format response
469            if ($result['success']) {
470                $response = [
471                    'success' => true,
472                    'redeemed_amount' => $result['redeemed_amount'],
473                    'remaining_value' => $result['remaining_value'],
474                    'coupon_status' => $result['coupon_status'],
475                    'redemption_method' => Redemption::METHOD_MANUAL,
476                ];
477
478                $this->sendJsonResponse($response);
479            } else {
480                // Map service error codes to HTTP status codes
481                $httpStatus = $this->mapRedemptionErrorToHttpStatus($result['code'] ?? 'UNKNOWN');
482
483                $response = [
484                    'success' => false,
485                    'error' => $result['error'],
486                    'code' => $result['code'] ?? null,
487                ];
488
489                // Include minimum required for MIN_PURCHASE_NOT_MET errors
490                if (isset($result['minimum_required'])) {
491                    $response['minimum_required'] = $result['minimum_required'];
492                }
493
494                $this->app->response->setStatus($httpStatus);
495                $this->sendJsonResponse($response);
496            }
497
498        } catch (Exception $e) {
499            error_log("ComebackCashApiController::redeemCoupon error: " . $e->getMessage());
500            $this->sendErrorResponse('Failed to process redemption', 500);
501        }
502    }
503
504    // =========================================================================
505    // REPORTING ENDPOINTS
506    // =========================================================================
507
508    /**
509     * GET /{typeNum}/api/comeback-cash/events/{id}/report
510     *
511     * Get statistics and reporting data for a specific event.
512     *
513     * @param int $id Event ID
514     *
515     * Response:
516     * {
517     *   "success": true,
518     *   "event_id": int,
519     *   "event_name": string,
520     *   "issued_count": int,
521     *   "redeemed_count": int,
522     *   "total_value_issued": decimal,
523     *   "total_value_redeemed": decimal,
524     *   "active_coupon_count": int,
525     *   "expired_coupon_count": int
526     * }
527     */
528    public function getEventReport(int $id): void
529    {
530        $this->setJsonContentType();
531
532        if (!$this->checkAuth()) {
533            return;
534        }
535
536        try {
537            $eventService = $this->factory->createEventService();
538
539            // Verify event exists
540            $event = $eventService->getEvent($id);
541            if (!$event) {
542                $this->sendErrorResponse('Event not found', 404, 'NOT_FOUND');
543                return;
544            }
545
546            // Get statistics from database
547            $stats = $this->getEventStatistics($id);
548
549            $response = [
550                'success' => true,
551                'event_id' => $id,
552                'event_name' => $event->name,
553                'issued_count' => $stats['issued_count'] ?? 0,
554                'redeemed_count' => $stats['redeemed_count'] ?? 0,
555                'total_value_issued' => $stats['total_value_issued'] ?? 0.00,
556                'total_value_redeemed' => $stats['total_value_redeemed'] ?? 0.00,
557                'active_coupon_count' => $stats['active_coupon_count'] ?? 0,
558                'expired_coupon_count' => $stats['expired_coupon_count'] ?? 0,
559            ];
560
561            $this->sendJsonResponse($response);
562
563        } catch (Exception $e) {
564            error_log("ComebackCashApiController::getEventReport error: " . $e->getMessage());
565            $this->sendErrorResponse('Failed to retrieve event report', 500);
566        }
567    }
568
569    // =========================================================================
570    // PRIVATE HELPER METHODS
571    // =========================================================================
572
573    /**
574     * Get event statistics from the database
575     *
576     * @param int $eventId Event ID
577     * @return array Statistics array
578     */
579    private function getEventStatistics(int $eventId): array
580    {
581        $db = $this->factory->getDatabase();
582
583        // Get coupon statistics
584        $sql = "SELECT
585                    COUNT(*) as issued_count,
586                    COALESCE(SUM(original_value), 0) as total_value_issued,
587                    SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active_coupon_count,
588                    SUM(CASE WHEN status = 'expired' THEN 1 ELSE 0 END) as expired_coupon_count,
589                    SUM(CASE WHEN status = 'redeemed' THEN 1 ELSE 0 END) as redeemed_count
590                FROM ccCoupons
591                WHERE event_id = :event_id";
592
593        $stmt = $db->prepare($sql);
594        $stmt->execute([':event_id' => $eventId]);
595        $couponStats = $stmt->fetch(\PDO::FETCH_ASSOC);
596
597        // Get redemption statistics
598        $sqlRedemptions = "SELECT
599                              COALESCE(SUM(r.redeemed_amount), 0) as total_value_redeemed
600                          FROM ccRedemptions r
601                          JOIN ccCoupons c ON r.coupon_id = c.id
602                          WHERE c.event_id = :event_id";
603
604        $stmt = $db->prepare($sqlRedemptions);
605        $stmt->execute([':event_id' => $eventId]);
606        $redemptionStats = $stmt->fetch(\PDO::FETCH_ASSOC);
607
608        return [
609            'issued_count' => (int) ($couponStats['issued_count'] ?? 0),
610            'redeemed_count' => (int) ($couponStats['redeemed_count'] ?? 0),
611            'total_value_issued' => (float) ($couponStats['total_value_issued'] ?? 0),
612            'total_value_redeemed' => (float) ($redemptionStats['total_value_redeemed'] ?? 0),
613            'active_coupon_count' => (int) ($couponStats['active_coupon_count'] ?? 0),
614            'expired_coupon_count' => (int) ($couponStats['expired_coupon_count'] ?? 0),
615        ];
616    }
617
618    /**
619     * Set JSON content type header
620     */
621    private function setJsonContentType(): void
622    {
623        $this->app->response->headers->set('Content-Type', 'application/json');
624    }
625
626    /**
627     * Send JSON response body
628     *
629     * @param array $data Response data
630     */
631    private function sendJsonResponse(array $data): void
632    {
633        $this->app->response->setBody(json_encode($data));
634    }
635
636    /**
637     * Send error response
638     *
639     * @param string $message Error message
640     * @param int $httpStatus HTTP status code
641     * @param string|null $errorCode Application error code
642     */
643    private function sendErrorResponse(string $message, int $httpStatus, ?string $errorCode = null): void
644    {
645        $response = [
646            'success' => false,
647            'error' => $message,
648        ];
649
650        if ($errorCode !== null) {
651            $response['code'] = $errorCode;
652        }
653
654        $this->app->response->setStatus($httpStatus);
655        $this->sendJsonResponse($response);
656    }
657
658    /**
659     * Parse JSON request body
660     *
661     * @return array|null Parsed data or null on error
662     */
663    private function getJsonRequestBody(): ?array
664    {
665        $body = $this->app->request->getBody();
666
667        if (empty($body)) {
668            // Try POST parameters as fallback
669            $post = $this->app->request->post();
670            if (!empty($post)) {
671                return $post;
672            }
673            return null;
674        }
675
676        $data = json_decode($body, true);
677
678        if (json_last_error() !== JSON_ERROR_NONE) {
679            return null;
680        }
681
682        return $data;
683    }
684
685    /**
686     * Validate redemption request data
687     *
688     * @param array $data Request data
689     * @return array|null Validation error or null if valid
690     */
691    private function validateRedemptionRequest(array $data): ?array
692    {
693        // Required: code
694        if (!isset($data['code']) || empty(trim($data['code']))) {
695            return [
696                'error' => 'code is required',
697                'status' => 400,
698            ];
699        }
700
701        // Required: transaction_id
702        if (!isset($data['transaction_id']) || empty(trim($data['transaction_id']))) {
703            return [
704                'error' => 'transaction_id is required',
705                'status' => 400,
706            ];
707        }
708
709        // Required: transaction_amount
710        if (!isset($data['transaction_amount'])) {
711            return [
712                'error' => 'transaction_amount is required',
713                'status' => 400,
714            ];
715        }
716
717        if (!is_numeric($data['transaction_amount']) || (float) $data['transaction_amount'] < 0) {
718            return [
719                'error' => 'transaction_amount must be a non-negative number',
720                'status' => 400,
721            ];
722        }
723
724        // Optional: amount_to_redeem validation
725        if (isset($data['amount_to_redeem'])) {
726            if (!is_numeric($data['amount_to_redeem']) || (float) $data['amount_to_redeem'] <= 0) {
727                return [
728                    'error' => 'amount_to_redeem must be a positive number',
729                    'status' => 400,
730                ];
731            }
732        }
733
734        return null;
735    }
736
737    /**
738     * Map redemption service error codes to HTTP status codes
739     *
740     * @param string $errorCode Service error code
741     * @return int HTTP status code
742     */
743    private function mapRedemptionErrorToHttpStatus(string $errorCode): int
744    {
745        return match ($errorCode) {
746            'INVALID_CODE' => 404,
747            'ALREADY_REDEEMED' => 409,
748            'EXPIRED' => 410,
749            'MIN_PURCHASE_NOT_MET' => 422,
750            'INVALID_AMOUNT' => 400,
751            'DATABASE_ERROR', 'REDEMPTION_ERROR' => 500,
752            default => 400,
753        };
754    }
755}