Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
Coupon
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 37
1892
0.00% covered (danger)
0.00%
0 / 1
 fromRow
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 generateCode
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getEventId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getValue
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getOriginalValue
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSourceTransactionId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSourceTransactionAmount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCustomerPhone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCustomerName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExpiresAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getIssuedAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getIssuedByEmployeeId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isExpired
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isRedeemable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 canRedeemForAmount
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getDisplayCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 markRedeemed
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 markExpired
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 reinstate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 markVoided
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 toArray
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 setId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setEventId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setValue
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOriginalValue
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSourceTransactionId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSourceTransactionAmount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCustomerPhone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCustomerName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setExpiresAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setIssuedAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setIssuedByEmployeeId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\ComebackCash\Models;
4
5use DateTime;
6use InvalidArgumentException;
7
8/**
9 * Coupon model for Comeback Cash system
10 *
11 * Represents a "bearer instrument" coupon that can be redeemed by anyone with the code.
12 * This is separate from the existing Loyalty system (per ADR-1).
13 *
14 * Coupons are issued when qualifying purchases are made and can be redeemed
15 * on future transactions that meet the minimum purchase requirement.
16 *
17 * Database table: ccCoupons
18 */
19class Coupon
20{
21    public const STATUS_ACTIVE = 'active';
22    public const STATUS_REDEEMED = 'redeemed';
23    public const STATUS_EXPIRED = 'expired';
24    public const STATUS_VOIDED = 'voided';
25
26    /**
27     * Characters used for code generation (excludes ambiguous: 0, O, I, L, 1)
28     */
29    private const CODE_CHARS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
30
31    /**
32     * Length of generated coupon codes
33     */
34    private const CODE_LENGTH = 8;
35
36    private int $id;
37    private int $eventId;
38    private string $code;
39    private float $value;
40    private float $originalValue;
41    private string $sourceTransactionId;
42    private float $sourceTransactionAmount;
43    private ?string $customerPhone;
44    private ?string $customerName;
45    private string $status;
46    private DateTime $expiresAt;
47    private DateTime $issuedAt;
48    private ?int $issuedByEmployeeId;
49
50    /**
51     * Create Coupon instance from database row
52     *
53     * @param array $row Associative array from database query
54     * @return self New Coupon instance
55     */
56    public static function fromRow(array $row): self
57    {
58        $coupon = new self();
59        $coupon->id = (int) $row['id'];
60        $coupon->eventId = (int) $row['event_id'];
61        $coupon->code = $row['code'];
62        $coupon->value = (float) $row['value'];
63        $coupon->originalValue = (float) $row['original_value'];
64        $coupon->sourceTransactionId = $row['source_transaction_id'];
65        $coupon->sourceTransactionAmount = (float) $row['source_transaction_amount'];
66        $coupon->customerPhone = $row['customer_phone'] ?? null;
67        $coupon->customerName = $row['customer_name'] ?? null;
68        $coupon->status = $row['status'] ?? self::STATUS_ACTIVE;
69        $coupon->expiresAt = new DateTime($row['expires_at']);
70        $coupon->issuedAt = new DateTime($row['issued_at']);
71        $coupon->issuedByEmployeeId = isset($row['issued_by_employee_id']) ? (int) $row['issued_by_employee_id'] : null;
72
73        return $coupon;
74    }
75
76    /**
77     * Generate a cryptographically secure 8-character coupon code
78     *
79     * Uses only unambiguous characters (excludes 0, O, I, l, 1) for
80     * easy reading and manual entry.
81     *
82     * @return string 8-character uppercase alphanumeric code
83     */
84    public static function generateCode(): string
85    {
86        $chars = self::CODE_CHARS;
87        $charsLength = strlen($chars);
88
89        $code = '';
90        $bytes = openssl_random_pseudo_bytes(self::CODE_LENGTH);
91
92        for ($i = 0; $i < self::CODE_LENGTH; $i++) {
93            $code .= $chars[ord($bytes[$i]) % $charsLength];
94        }
95
96        return $code;
97    }
98
99    /**
100     * Get coupon ID
101     * @return int
102     */
103    public function getId(): int
104    {
105        return $this->id;
106    }
107
108    /**
109     * Get associated event ID
110     * @return int
111     */
112    public function getEventId(): int
113    {
114        return $this->eventId;
115    }
116
117    /**
118     * Get coupon code
119     * @return string
120     */
121    public function getCode(): string
122    {
123        return $this->code;
124    }
125
126    /**
127     * Get current value (remaining value if partially redeemed)
128     * @return float
129     */
130    public function getValue(): float
131    {
132        return $this->value;
133    }
134
135    /**
136     * Get original value when issued
137     * @return float
138     */
139    public function getOriginalValue(): float
140    {
141        return $this->originalValue;
142    }
143
144    /**
145     * Get source transaction ID (the purchase that earned this coupon)
146     * @return string
147     */
148    public function getSourceTransactionId(): string
149    {
150        return $this->sourceTransactionId;
151    }
152
153    /**
154     * Get source transaction amount
155     * @return float
156     */
157    public function getSourceTransactionAmount(): float
158    {
159        return $this->sourceTransactionAmount;
160    }
161
162    /**
163     * Get customer phone number
164     * @return string|null
165     */
166    public function getCustomerPhone(): ?string
167    {
168        return $this->customerPhone;
169    }
170
171    /**
172     * Get customer name
173     * @return string|null
174     */
175    public function getCustomerName(): ?string
176    {
177        return $this->customerName;
178    }
179
180    /**
181     * Get current status
182     * @return string One of: active, redeemed, expired, voided
183     */
184    public function getStatus(): string
185    {
186        return $this->status;
187    }
188
189    /**
190     * Get expiration date/time
191     * @return DateTime
192     */
193    public function getExpiresAt(): DateTime
194    {
195        return $this->expiresAt;
196    }
197
198    /**
199     * Get issue date/time
200     * @return DateTime
201     */
202    public function getIssuedAt(): DateTime
203    {
204        return $this->issuedAt;
205    }
206
207    /**
208     * Get ID of employee who issued the coupon
209     * @return int|null
210     */
211    public function getIssuedByEmployeeId(): ?int
212    {
213        return $this->issuedByEmployeeId;
214    }
215
216    /**
217     * Check if the coupon is expired
218     * @return bool
219     */
220    public function isExpired(): bool
221    {
222        return $this->expiresAt < new DateTime();
223    }
224
225    /**
226     * Check if coupon can be redeemed
227     *
228     * A coupon is redeemable if:
229     * - Status is 'active'
230     * - Not expired (expiresAt is in the future)
231     *
232     * @return bool True if coupon can be redeemed
233     */
234    public function isRedeemable(): bool
235    {
236        return $this->status === self::STATUS_ACTIVE && !$this->isExpired();
237    }
238
239    /**
240     * Check if coupon can be redeemed for a specific transaction amount
241     *
242     * Validates both redeemability and minimum purchase requirement.
243     *
244     * @param float $transactionAmount The total transaction amount
245     * @param float $minPurchase The minimum purchase required to redeem
246     * @return bool True if coupon can be applied to this transaction
247     */
248    public function canRedeemForAmount(float $transactionAmount, float $minPurchase): bool
249    {
250        if (!$this->isRedeemable()) {
251            return false;
252        }
253
254        return $transactionAmount >= $minPurchase;
255    }
256
257    /**
258     * Get display-formatted code with hyphen
259     *
260     * Formats the 8-character code as "XXXX-XXXX" for easier reading.
261     *
262     * @return string Formatted code (e.g., "ABCD-EF12")
263     */
264    public function getDisplayCode(): string
265    {
266        return substr($this->code, 0, 4) . '-' . substr($this->code, 4, 4);
267    }
268
269    /**
270     * Mark coupon as redeemed
271     *
272     * Updates status to 'redeemed' and reduces value by the amount used.
273     * For full redemption, amount should equal current value.
274     *
275     * @param float $amount The amount being redeemed
276     * @throws InvalidArgumentException If amount exceeds current value
277     */
278    public function markRedeemed(float $amount): void
279    {
280        if ($amount > $this->value) {
281            throw new InvalidArgumentException(
282                "Redemption amount ({$amount}) exceeds coupon value ({$this->value})"
283            );
284        }
285
286        $this->value = $this->value - $amount;
287
288        // If fully redeemed (value is now 0 or negligibly small), mark as redeemed
289        if ($this->value < 0.01) {
290            $this->value = 0;
291            $this->status = self::STATUS_REDEEMED;
292        }
293    }
294
295    /**
296     * Mark coupon as expired
297     *
298     * Sets status to 'expired'. Typically called by a batch process
299     * when checking for expired coupons.
300     */
301    public function markExpired(): void
302    {
303        $this->status = self::STATUS_EXPIRED;
304    }
305
306    /**
307     * Reinstate a coupon to active status
308     *
309     * Used when a refund policy requires reinstating the coupon
310     * instead of refunding cash. Restores value to original value.
311     */
312    public function reinstate(): void
313    {
314        $this->status = self::STATUS_ACTIVE;
315        $this->value = $this->originalValue;
316    }
317
318    /**
319     * Mark coupon as voided
320     *
321     * Voids the coupon (e.g., for fraud prevention or customer request).
322     */
323    public function markVoided(): void
324    {
325        $this->status = self::STATUS_VOIDED;
326    }
327
328    /**
329     * Convert to array for API responses and serialization
330     *
331     * @return array Associative array of all coupon data
332     */
333    public function toArray(): array
334    {
335        return [
336            'id' => $this->id,
337            'eventId' => $this->eventId,
338            'code' => $this->code,
339            'displayCode' => $this->getDisplayCode(),
340            'value' => $this->value,
341            'originalValue' => $this->originalValue,
342            'sourceTransactionId' => $this->sourceTransactionId,
343            'sourceTransactionAmount' => $this->sourceTransactionAmount,
344            'customerPhone' => $this->customerPhone,
345            'customerName' => $this->customerName,
346            'status' => $this->status,
347            'expiresAt' => $this->expiresAt->format('Y-m-d H:i:s'),
348            'issuedAt' => $this->issuedAt->format('Y-m-d H:i:s'),
349            'issuedByEmployeeId' => $this->issuedByEmployeeId,
350            'isRedeemable' => $this->isRedeemable(),
351            'isExpired' => $this->isExpired(),
352        ];
353    }
354
355    /**
356     * Set coupon ID (for newly inserted records)
357     *
358     * @param int $id The database-assigned ID
359     */
360    public function setId(int $id): void
361    {
362        $this->id = $id;
363    }
364
365    /**
366     * Set event ID
367     *
368     * @param int $eventId The event ID
369     */
370    public function setEventId(int $eventId): void
371    {
372        $this->eventId = $eventId;
373    }
374
375    /**
376     * Set coupon code
377     *
378     * @param string $code The coupon code
379     */
380    public function setCode(string $code): void
381    {
382        $this->code = $code;
383    }
384
385    /**
386     * Set coupon value
387     *
388     * @param float $value The value
389     */
390    public function setValue(float $value): void
391    {
392        $this->value = $value;
393    }
394
395    /**
396     * Set original value
397     *
398     * @param float $originalValue The original value
399     */
400    public function setOriginalValue(float $originalValue): void
401    {
402        $this->originalValue = $originalValue;
403    }
404
405    /**
406     * Set source transaction ID
407     *
408     * @param string $sourceTransactionId The source transaction ID
409     */
410    public function setSourceTransactionId(string $sourceTransactionId): void
411    {
412        $this->sourceTransactionId = $sourceTransactionId;
413    }
414
415    /**
416     * Set source transaction amount
417     *
418     * @param float $sourceTransactionAmount The source transaction amount
419     */
420    public function setSourceTransactionAmount(float $sourceTransactionAmount): void
421    {
422        $this->sourceTransactionAmount = $sourceTransactionAmount;
423    }
424
425    /**
426     * Set customer phone
427     *
428     * @param string|null $customerPhone The customer phone
429     */
430    public function setCustomerPhone(?string $customerPhone): void
431    {
432        $this->customerPhone = $customerPhone;
433    }
434
435    /**
436     * Set customer name
437     *
438     * @param string|null $customerName The customer name
439     */
440    public function setCustomerName(?string $customerName): void
441    {
442        $this->customerName = $customerName;
443    }
444
445    /**
446     * Set status
447     *
448     * @param string $status The status
449     */
450    public function setStatus(string $status): void
451    {
452        $this->status = $status;
453    }
454
455    /**
456     * Set expiration date/time
457     *
458     * @param DateTime $expiresAt The expiration date/time
459     */
460    public function setExpiresAt(DateTime $expiresAt): void
461    {
462        $this->expiresAt = $expiresAt;
463    }
464
465    /**
466     * Set issue date/time
467     *
468     * @param DateTime $issuedAt The issue date/time
469     */
470    public function setIssuedAt(DateTime $issuedAt): void
471    {
472        $this->issuedAt = $issuedAt;
473    }
474
475    /**
476     * Set employee ID who issued the coupon
477     *
478     * @param int|null $issuedByEmployeeId The employee ID
479     */
480    public function setIssuedByEmployeeId(?int $issuedByEmployeeId): void
481    {
482        $this->issuedByEmployeeId = $issuedByEmployeeId;
483    }
484}