'int', 'restaurant_id' => 'int', 'product_id' => 'int', 'payment_id' => 'int', 'initial_amount' => 'int', 'remaining_amount' => 'int', 'delivered' => 'boolean', 'settings' => 'array', ]; public static function booted() { static::creating(function ($voucher) { if (empty($voucher->uuid)) { // The uuid is the public handle of the voucher page (?myvoucher=), // which shows the redemption code: it must NOT be guessable, so we // use a random token instead of the time-based uniqid() of Str::uuid. $voucher->uuid = 'gv' . wp_generate_password(22, false, false); } if (empty($voucher->code)) { $voucher->code = static::generateUniqueCode(); } if (empty($voucher->status)) { $voucher->status = VoucherStatus::PENDING_PAYMENT; } if ($voucher->type === null || $voucher->type === '') { $voucher->type = self::TYPE_AMOUNT; } }); } public function restaurant() { return $this->belongsTo(Restaurant::class); } public function redemptions() { return $this->hasMany(VoucherRedemption::class); } public function payments() { return $this->hasMany(Payment::class); } /** * Generate a globally-unique redemption code (GIFT-XXXX-XXXX). * The UNIQUE KEY on `code` is the real guard; this pre-check just * avoids insert failures on the astronomically rare collision. */ public static function generateUniqueCode($maxAttempts = 12) { for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { $code = 'GIFT-' . static::randomBlock(4) . '-' . static::randomBlock(4); if (!static::where('code', $code)->first()) { return $code; } } // Fallback: still unambiguous namespace, higher entropy return 'GIFT-' . static::randomBlock(4) . '-' . static::randomBlock(4) . '-' . static::randomBlock(4); } protected static function randomBlock($length) { $alphabet = self::CODE_ALPHABET; $max = strlen($alphabet) - 1; $out = ''; for ($i = 0; $i < $length; $i++) { $out .= $alphabet[random_int(0, $max)]; } return $out; } /** * Remaining balance in the smallest currency unit (cents). */ public function balance() { return max(0, intval($this->remaining_amount)); } public function hasBalance() { return $this->balance() > 0; } /** * True when the voucher is past its expiry date. A null expires_at * means "no expiry". Compared in the restaurant timezone. */ public function isExpired() { if (empty($this->expires_at)) { return false; } $today = $this->restaurantToday(); return $this->expires_at < $today; } /** * Whether the voucher can currently be redeemed: active status, * has balance, and not expired. */ public function isRedeemable() { return in_array($this->status, VoucherStatus::redeemable(), true) && $this->hasBalance() && !$this->isExpired(); } /** * Issue a paid voucher: pending_payment -> active. Idempotent (a no-op if * already active/redeemed) and refuses to resurrect a cancelled/expired one. * Sets the expiry from the product's stored months, guarantees the balance * is funded, and links the payment. Returns true only when it activated. */ public function markPaid($payment = null) { global $wpdb; if (in_array($this->status, [VoucherStatus::ACTIVE, VoucherStatus::REDEEMED], true)) { return false; // already issued } if ($this->status !== VoucherStatus::PENDING_PAYMENT) { return false; // cancelled / expired - do not resurrect } $remaining = intval($this->remaining_amount) > 0 ? intval($this->remaining_amount) : intval($this->initial_amount); if (empty($this->expires_at)) { $this->applyExpiry($this->productExpiryMonths()); } $paymentId = ($payment && !empty($payment->id)) ? intval($payment->id) : null; // ATOMIC transition pending_payment -> active. Two concurrent gateway // returns (or a webhook + a browser return) race here: only the one // that flips the row gets `true`, so the issued hook fires exactly once. $table = \SRR_DB_Vouchers::$table_db; $now = current_time('mysql'); $sql = $wpdb->prepare( "UPDATE {$table} SET status = %s, remaining_amount = %d, expires_at = %s, payment_id = %s, date_modified = %s WHERE id = %d AND status = %s", VoucherStatus::ACTIVE, $remaining, $this->expires_at, $paymentId, $now, intval($this->id), VoucherStatus::PENDING_PAYMENT ); // %s on a NULL payment_id would become '' — patch it to a real NULL. if ($paymentId === null) { $sql = str_replace("payment_id = ''", 'payment_id = NULL', $sql); } if ($this->expires_at === null) { $sql = str_replace("expires_at = ''", 'expires_at = NULL', $sql); } $affected = $wpdb->query($sql); if ($affected !== 1) { // Lost the race (or already issued): reload the persisted state. $fresh = static::find($this->id); if ($fresh) { $this->status = $fresh->status; $this->remaining_amount = $fresh->remaining_amount; $this->expires_at = $fresh->expires_at; $this->payment_id = $fresh->payment_id; } return false; } $this->status = VoucherStatus::ACTIVE; $this->remaining_amount = $remaining; $this->payment_id = $paymentId; $this->date_modified = $now; return true; } /** * Set expires_at to N months from today in the restaurant timezone. * months of 0 (or null) means "no expiry". */ public function applyExpiry($months) { $months = intval($months); if ($months <= 0) { $this->expires_at = null; return; } try { $dt = new \DateTime($this->restaurantToday()); $dt->modify('+' . $months . ' months'); $this->expires_at = $dt->format('Y-m-d'); } catch (\Exception $e) { $this->expires_at = null; } } /** * Expiry months captured from the gift card product at order time * (settings['expiry_months']), defaulting to 12. */ protected function productExpiryMonths() { $settings = is_array($this->settings) ? $this->settings : []; if (isset($settings['expiry_months']) && $settings['expiry_months'] !== '') { return intval($settings['expiry_months']); } return 12; } /** * Current date (Y-m-d) in the restaurant timezone, falling back to * WordPress site time when the restaurant is unavailable. */ protected function restaurantToday() { $restaurant = $this->restaurant; if ($restaurant && !empty($restaurant->timezone)) { try { $tz = new \DateTimeZone($restaurant->timezone); $now = new \DateTime('now', $tz); return $now->format('Y-m-d'); } catch (\Exception $e) { // fall through } } return current_time('Y-m-d'); } /** * Create a pending_payment voucher "order" from a GiftCard product + buyer * data. The amount is ALWAYS computed server-side from the product (the * client value is only honoured, and only within min/max, for free-amount * products). Throws \InvalidArgumentException on any invalid input. * * @param array $params restaurant_id, product_id, amount (free type only), * buyer_name/email/phone, recipient_name/email, * message, delivery_date, language * @return Voucher the persisted pending_payment voucher (uuid + code set) */ public static function createOrder(array $params) { $restaurantId = intval($params['restaurant_id'] ?? 0); $productId = intval($params['product_id'] ?? 0); $restaurant = Restaurant::find($restaurantId); if (!$restaurant) { throw new \InvalidArgumentException(__eva('Invalid restaurant')); } $product = GiftCard::find($productId); if (!$product || intval($product->restaurant_id) !== $restaurantId) { throw new \InvalidArgumentException(__eva('Invalid gift voucher')); } if (!$product->active) { throw new \InvalidArgumentException(__eva('This gift voucher is not available')); } // Amount + voucher type, computed server-side from the product. list($voucherType, $amountCents) = self::resolveAmountForProduct($product, $params['amount'] ?? null); if ($amountCents <= 0) { throw new \InvalidArgumentException(__eva('Invalid amount')); } $buyerEmail = sanitize_email($params['buyer_email'] ?? ''); if (!$buyerEmail || !is_email($buyerEmail)) { throw new \InvalidArgumentException(__eva('A valid buyer email is required')); } $voucher = new self(); $voucher->restaurant_id = $restaurantId; $voucher->product_id = $productId; $voucher->type = $voucherType; $voucher->initial_amount = $amountCents; $voucher->remaining_amount = $amountCents; $voucher->currency = $restaurant->currency; $voucher->buyer_name = sanitize_text_field($params['buyer_name'] ?? ''); $voucher->buyer_email = $buyerEmail; $voucher->buyer_phone = sanitize_text_field($params['buyer_phone'] ?? ''); $voucher->recipient_name = sanitize_text_field($params['recipient_name'] ?? ''); $voucher->recipient_email = sanitize_email($params['recipient_email'] ?? ''); $voucher->message = sanitize_textarea_field($params['message'] ?? ''); $voucher->delivery_date = self::sanitizeDeliveryDate($params['delivery_date'] ?? null); $language = sanitize_text_field((string) ($params['language'] ?? '')); $voucher->language = $language !== '' ? $language : (string) $restaurant->language; // status / uuid / code are set in booted() (pending_payment). $voucher->settings = [ 'product_name' => $product->name, 'product_type' => $product->type, 'expiry_months' => $product->expiry_months, ]; $voucher->save(); return $voucher; } /** * Resolve [voucherType, amountInCents] for a gift card product. Fixed and * experience products use the product's own amount; free-amount products use * the buyer's value validated against the product min/max. */ protected static function resolveAmountForProduct($product, $clientAmount) { switch ($product->type) { case GiftCard::TYPE_FIXED_AMOUNT: return [self::TYPE_AMOUNT, self::toCents($product->amount)]; case GiftCard::TYPE_EXPERIENCE: return [self::TYPE_EXPERIENCE, self::toCents($product->amount)]; case GiftCard::TYPE_FREE_AMOUNT: $value = self::toCents($clientAmount); $min = self::toCents($product->amount_min); $max = self::toCents($product->amount_max); if ($value <= 0) { throw new \InvalidArgumentException(__eva('Please choose an amount')); } if ($min > 0 && $value < $min) { throw new \InvalidArgumentException(__eva('The amount is below the minimum')); } if ($max > 0 && $value > $max) { throw new \InvalidArgumentException(__eva('The amount is above the maximum')); } return [self::TYPE_AMOUNT, $value]; default: throw new \InvalidArgumentException(__eva('Invalid gift voucher type')); } } protected static function toCents($amount) { return intval(round(floatval($amount) * 100)); } /** * Accepts a future Y-m-d delivery date; anything empty, malformed, or not in * the future resolves to null (deliver immediately). */ protected static function sanitizeDeliveryDate($value) { $value = trim((string) $value); if ($value === '') { return null; } $parts = explode('-', $value); if (count($parts) !== 3 || !checkdate(intval($parts[1]), intval($parts[2]), intval($parts[0]))) { return null; } if ($value <= current_time('Y-m-d')) { return null; // today or past -> immediate delivery } return $value; } }