offers_active == 'yes'; } public function getOffersRules() { $rules = $this->offers_rules; if (is_array($rules)) { return $rules; } return []; } /** * Iterate the offer rules and return the first matching for the given * date/time and (optionally) party size. Returns null if no offer rule * matches or offers are disabled. * * @param string $date Y-m-d * @param int $time seconds from midnight * @param int|null $party party size; when null the min/max_guests check * on each rule is skipped. * * @return array|null */ public function getOfferRuleMatched($date, $time, $party = null) { if (!defined('ALEXR_PRO_VERSION')) return null; if (!$this->isOffersActive()) return null; foreach ($this->getOffersRules() as $rule) { if ($this->matchesScheduleRule($rule, $date, $time, $party)) { return $rule; } } return null; } public function getOfferRuleMatchedForBooking($booking) { return $this->getOfferRuleMatched($booking->date, $booking->time, $booking->party); } /** * Compute the amount to charge for an offer rule. * * Pricing modes: * - 'fixed' : flat amount per reservation * - 'per_seat' : price per cover * party * - 'percentage_of_menu' : percentage of the menu subtotal selected by the customer * * @param array $rule * @param int $party * @param float $menu_subtotal subtotal of the offer/menu selection coming from the booking * * @return float */ public function getOfferRulePrice($rule, $party, $menu_subtotal = 0) { if (!isset($rule['payment_type'])) return 0; $payment_type = $rule['payment_type']; if ($payment_type == 'fixed') { return isset($rule['price_fixed']) ? floatval($rule['price_fixed']) : 0; } if ($payment_type == 'per_seat') { return isset($rule['price_per_seat']) ? intval($party) * floatval($rule['price_per_seat']) : 0; } if ($payment_type == 'percentage_of_menu') { $pct = isset($rule['price_percentage']) ? floatval($rule['price_percentage']) : 0; if ($pct <= 0) return 0; return floatval($menu_subtotal) * $pct / 100; } return 0; } /** * Build a public payload for an offer ({id, name, enabled, sections}), * suitable for rendering the menu selector in the widget / booking view. * Returns null when the offer cannot be located for the restaurant. * * @param int $offer_id * * @return array|null */ public function getOfferPayload($offer_id) { $offer_id = intval($offer_id); if ($offer_id <= 0) return null; $restaurant_id = $this->restaurant_id ?? null; if (!$restaurant_id) return null; $offer = \Alexr\Settings\Offer::where('restaurant_id', $restaurant_id) ->where('id', $offer_id) ->first(); if (!$offer) return null; $sections = $offer->sections; if (is_string($sections) && $sections !== '') { $decoded = json_decode($sections, true); if (is_array($decoded)) $sections = $decoded; } if (!is_array($sections)) $sections = []; return [ 'id' => $offer->id, 'name' => $offer->name, 'enabled' => (bool) $offer->enabled, 'sections' => $sections, ]; } /** * Load and decode the sections of an offer. * Returns an empty array when the offer cannot be located for the restaurant. * * @param int $offer_id * * @return array */ protected function loadOfferSections($offer_id) { $offer_id = intval($offer_id); if ($offer_id <= 0) return []; $restaurant_id = $this->restaurant_id ?? null; if (!$restaurant_id) return []; $offer = \Alexr\Settings\Offer::where('restaurant_id', $restaurant_id) ->where('id', $offer_id) ->first(); if (!$offer) return []; $sections = $offer->sections; if (is_string($sections) && $sections !== '') { $decoded = json_decode($sections, true); if (is_array($decoded)) $sections = $decoded; } if (!is_array($sections)) return []; return $sections; } /** * Build a lookup map [section_id][item_id] => item from offer sections. * * @param array $sections * * @return array */ protected function indexOfferItems($sections) { $index = []; foreach ($sections as $section) { if (!isset($section['id']) || !isset($section['items'])) continue; $index[$section['id']] = []; foreach ($section['items'] as $item) { if (!isset($item['id'])) continue; $index[$section['id']][$item['id']] = $item; } } return $index; } /** * Per-unit price for an offer item, taking the section type and the item's * price_mode into account. Mirrors the front-end pricing * (OfferPreviewModal / WidgetMenuSelectionStep): * * - per_guest_required / per_guest_optional: flat item price per unit. * - reservation_optional / single_offer_choice: price_mode aware * · flat → price * · per_guest → price * party * · base_plus_per_guest → base_price + per_guest_price * party * * @param string|null $section_type * @param array $item * @param int $party * * @return float */ protected function unitPriceForItem($section_type, $item, $party) { $party = max(1, intval($party)); if ($section_type === 'reservation_optional' || $section_type === 'single_offer_choice') { $mode = isset($item['price_mode']) ? $item['price_mode'] : 'flat'; if ($mode === 'per_guest') { return floatval($item['price'] ?? 0) * $party; } if ($mode === 'base_plus_per_guest') { return floatval($item['base_price'] ?? 0) + floatval($item['per_guest_price'] ?? 0) * $party; } return floatval($item['price'] ?? 0); } // per_guest_required / per_guest_optional / booking_optional (legacy) return floatval($item['price'] ?? 0); } /** * Recompute the authoritative menu subtotal from a list of selected items. * Client-side prices in $menu_lines are ignored — the price stored in the * offer record (server side) is the source of truth. * * @param int $offer_id * @param array $menu_lines each line: ['section_id' => string, 'item_id' => string, 'quantity' => int] * @param int $party number of guests (needed for per_guest pricing modes) * * @return float */ public function recomputeMenuSubtotal($offer_id, $menu_lines, $party = 1) { if (!is_array($menu_lines) || empty($menu_lines)) return 0; $sections = $this->loadOfferSections($offer_id); if (empty($sections)) return 0; $section_by_id = []; foreach ($sections as $section) { if (isset($section['id'])) $section_by_id[$section['id']] = $section; } $index = $this->indexOfferItems($sections); $subtotal = 0; foreach ($menu_lines as $line) { if (!is_array($line)) continue; $section_id = isset($line['section_id']) ? $line['section_id'] : null; $item_id = isset($line['item_id']) ? $line['item_id'] : null; $qty = isset($line['quantity']) ? intval($line['quantity']) : 0; if (!$section_id || !$item_id || $qty <= 0) continue; if (!isset($index[$section_id][$item_id])) continue; $item = $index[$section_id][$item_id]; $section_type = isset($section_by_id[$section_id]['type']) ? $section_by_id[$section_id]['type'] : null; $subtotal += $this->unitPriceForItem($section_type, $item, $party) * $qty; } return $subtotal; } /** * Sanitise the menu lines for persistence: only keep lines that map to a * known offer item, and replace the price/subtotal with the server price. * * @param int $offer_id * @param array $menu_lines * @param int $party number of guests (needed for per_guest pricing modes) * * @return array list of clean lines */ public function sanitiseMenuLines($offer_id, $menu_lines, $party = 1) { if (!is_array($menu_lines) || empty($menu_lines)) return []; $sections = $this->loadOfferSections($offer_id); if (empty($sections)) return []; $section_by_id = []; foreach ($sections as $section) { if (isset($section['id'])) $section_by_id[$section['id']] = $section; } $index = $this->indexOfferItems($sections); $clean = []; foreach ($menu_lines as $line) { if (!is_array($line)) continue; $section_id = isset($line['section_id']) ? $line['section_id'] : null; $item_id = isset($line['item_id']) ? $line['item_id'] : null; $qty = isset($line['quantity']) ? intval($line['quantity']) : 0; if (!$section_id || !$item_id || $qty <= 0) continue; if (!isset($index[$section_id][$item_id])) continue; $item = $index[$section_id][$item_id]; $section_type = isset($section_by_id[$section_id]['type']) ? $section_by_id[$section_id]['type'] : null; $unit_price = $this->unitPriceForItem($section_type, $item, $party); $clean[] = [ 'section_id' => $section_id, 'section_type' => $section_type, 'item_id' => $item_id, 'name' => isset($item['name']) ? $item['name'] : null, 'quantity' => $qty, 'unit_price' => $unit_price, 'subtotal' => $unit_price * $qty, ]; } return $clean; } }