PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.2
Yatra – Travel Booking & Tour Operator Software v3.0.14.2
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Repositories / DepartureRepository.php

DepartureRepository.php in Yatra – Travel Booking & Tour Operator Software 3.0.14.2, at app/Repositories/DepartureRepository.php

738 lines 28.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Repositories;
6
7 use Yatra\Models\Departure;
8 use Yatra\Database\Tables\DeparturesTable;
9
10 /**
11 * Departure Repository
12 * Handles database operations for trip departures
13 *
14 * Table: wp_yatra_trip_departures
15 *
16 * Fields:
17 * - id (primary key)
18 * - trip_id
19 * - date (YYYY-MM-DD)
20 * - time (HH:MM:SS, nullable)
21 * - max_capacity
22 * - booked_count
23 * - status (upcoming|full|past|cancelled)
24 * - source (manual|recurring_generated)
25 * - price_override (nullable)
26 * - price_by_traveler_type (JSON)
27 * - notes (nullable)
28 * - created_at
29 * - updated_at
30 */
31 class DepartureRepository extends BaseRepository
32 {
33 /**
34 * Get table name
35 */
36 protected function getTableName(): string
37 {
38 return DeparturesTable::getTableName();
39 }
40
41 /**
42 * Find by ID
43 */
44 public function find(int $id, bool $includeDeleted = false): ?\stdClass
45 {
46 $result = parent::find($id, $includeDeleted);
47 return $result ? (object) Departure::fromArray((array) $result)->toArray() : null;
48 }
49
50 /**
51 * Find by ID and return Departure model
52 */
53 public function findModel(int $id): ?Departure
54 {
55 $result = parent::find($id);
56 return $result ? Departure::fromArray((array) $result) : null;
57 }
58
59 /**
60 * Find a departure by trip, start date (or date), and optional time
61 */
62 public function findByTripIdAndStartDate(int $tripId, string $date, ?string $time = null): ?Departure
63 {
64 $table = esc_sql($this->table);
65 $where = ['trip_id = %d'];
66 $params = [$tripId];
67
68 // prefer start_date if exists, else fall back to date column
69 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
70 $hasStartDate = in_array('start_date', $columns, true);
71
72 if ($hasStartDate) {
73 $where[] = '((start_date IS NOT NULL AND start_date = %s) OR (start_date IS NULL AND date = %s))';
74 $params[] = $date;
75 $params[] = $date;
76 } else {
77 $where[] = 'date = %s';
78 $params[] = $date;
79 }
80
81 if ($time !== null && $time !== '') {
82 $where[] = 'time = %s';
83 $params[] = $time;
84 }
85
86 $query = "SELECT * FROM {$table} WHERE " . implode(' AND ', $where) . " LIMIT 1";
87 $row = $this->wpdb->get_row($this->wpdb->prepare($query, ...$params), ARRAY_A);
88
89 return $row ? Departure::fromArray($row) : null;
90 }
91
92 /**
93 * Find all departures across all trips
94 *
95 * @param array $filters Filters: status, date_from, date_to, source
96 * @return array Array of Departure models
97 */
98 public function findAll(array $filters = []): array
99 {
100
101 $table = esc_sql($this->table);
102 $where = ['1=1']; // Always true for base condition
103 $params = [];
104
105 // Status filter. 'past' is date-derived (see Departure::calculateStatus),
106 // NOT the stored status column — that column is only kept current by the
107 // daily cron, so filtering status = 'past' hid every departure that had
108 // taken place whenever the cron had not run. Match 'past' by date instead;
109 // all other statuses (upcoming/full/cancelled/trash) use the stored value.
110 $statusIsPast = (!empty($filters['status']) && $filters['status'] === 'past');
111 if (!empty($filters['status']) && $filters['status'] !== 'all') {
112 if ($statusIsPast) {
113 $where[] = "(
114 (end_date IS NOT NULL AND end_date < CURDATE())
115 OR (end_date IS NULL AND start_date IS NOT NULL AND start_date < CURDATE())
116 OR (end_date IS NULL AND start_date IS NULL AND date < CURDATE())
117 )";
118 } else {
119 $where[] = 'status = %s';
120 $params[] = $filters['status'];
121 }
122 }
123
124 // Date range filter - simple approach
125 if (isset($filters['date_from']) && is_string($filters['date_from']) && trim($filters['date_from']) !== '') {
126 $dateFrom = trim($filters['date_from']);
127 // Validate date format AND that it's a real date
128 if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) && strtotime($dateFrom) !== false) {
129 $where[] = 'date >= %s';
130 $params[] = $dateFrom;
131 }
132 }
133
134 if (isset($filters['date_to']) && is_string($filters['date_to']) && trim($filters['date_to']) !== '') {
135 $dateTo = trim($filters['date_to']);
136 // Validate date format AND that it's a real date
137 if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo) && strtotime($dateTo) !== false) {
138 $where[] = 'date <= %s';
139 $params[] = $dateTo;
140 }
141 }
142
143 // Source filter
144 if (!empty($filters['source']) && $filters['source'] !== 'all') {
145 $where[] = 'source = %s';
146 $params[] = $filters['source'];
147 }
148
149 // Past/upcoming filter. Never exclude past dates when the caller is asking
150 // for the 'past' status — that combination is contradictory and returned
151 // nothing (the exact reason completed departures vanished from the Past tab).
152 if (isset($filters['include_past'])) {
153 if (!$filters['include_past'] && !$statusIsPast) {
154 $where[] = 'date >= CURDATE()';
155 }
156 }
157
158 // Past-only filter — for the "Past Departures" archive. Deliberately
159 // DATE-based (mirrors the end_date ?: start_date ?: date precedence used
160 // when marking departures past) rather than checking status = 'past':
161 // that stored status is only kept current by the daily cron, so relying
162 // on it made departures that had already taken place disappear entirely
163 // whenever the cron had not run. Date is the source of truth.
164 if (!empty($filters['past_only'])) {
165 // start_date / end_date are nullable DATE columns (never ''), so guard
166 // with IS NULL / IS NOT NULL — comparing a DATE column to '' errors
167 // under MySQL strict mode.
168 $where[] = "(
169 (end_date IS NOT NULL AND end_date < CURDATE())
170 OR (end_date IS NULL AND start_date IS NOT NULL AND start_date < CURDATE())
171 OR (end_date IS NULL AND start_date IS NULL AND date < CURDATE())
172 )";
173 }
174
175 $query = "SELECT * FROM `{$table}` WHERE " . implode(' AND ', $where);
176 $query .= " ORDER BY date ASC, time ASC";
177
178 if (!empty($filters['per_page'])) {
179 $perPage = (int) $filters['per_page'];
180 $page = max(1, (int) ($filters['page'] ?? 1));
181 $offset = ($page - 1) * $perPage;
182 $query .= " LIMIT %d OFFSET %d";
183 $params[] = $perPage;
184 $params[] = $offset;
185 }
186
187 // Every dynamic value in this query goes through $params, so with no
188 // filters applied the SQL carries no placeholders at all — and calling
189 // prepare() on a placeholder-free query is what WordPress warns about
190 // ("The query argument of wpdb::prepare() must have a placeholder").
191 // Only prepare when there is something to bind.
192 $results = empty($params)
193 ? $this->wpdb->get_results($query, ARRAY_A)
194 : $this->wpdb->get_results($this->wpdb->prepare($query, ...$params), ARRAY_A);
195
196 if ($this->wpdb->last_error) {
197 }
198
199 return array_map(function ($row) {
200 return Departure::fromArray($row);
201 }, $results ?: []);
202 }
203
204 /**
205 * Find departures by trip ID
206 *
207 * @param int $tripId Trip ID
208 * @param array $filters Filters: status, date_from, date_to, source
209 * @return array Array of Departure models
210 */
211 public function findByTripId(int $tripId, array $filters = []): array
212 {
213
214 $table = esc_sql($this->table);
215 $where = ['trip_id = %d'];
216 $params = [$tripId];
217
218 // Status filter. 'past' is date-derived (see Departure::calculateStatus),
219 // NOT the stored status column — that column is only kept current by the
220 // daily cron, so filtering status = 'past' hid every departure that had
221 // taken place whenever the cron had not run. Match 'past' by date instead;
222 // all other statuses (upcoming/full/cancelled/trash) use the stored value.
223 $statusIsPast = (!empty($filters['status']) && $filters['status'] === 'past');
224 if (!empty($filters['status']) && $filters['status'] !== 'all') {
225 if ($statusIsPast) {
226 $where[] = "(
227 (end_date IS NOT NULL AND end_date < CURDATE())
228 OR (end_date IS NULL AND start_date IS NOT NULL AND start_date < CURDATE())
229 OR (end_date IS NULL AND start_date IS NULL AND date < CURDATE())
230 )";
231 } else {
232 $where[] = 'status = %s';
233 $params[] = $filters['status'];
234 }
235 }
236
237 // Date range filter - check both start_date and date columns
238 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
239 $hasStartDate = in_array('start_date', $columns, true);
240
241 if (!empty($filters['date_from']) && trim($filters['date_from']) !== '') {
242 if ($hasStartDate) {
243 // NOTE: start_date is a DATE column in some installs; comparing to "" can trigger
244 // "Incorrect DATE value: ''" under strict SQL modes. Treat "0000-00-00" as empty.
245 $where[] = '((start_date IS NOT NULL AND start_date <> %s AND start_date >= %s) OR ((start_date IS NULL OR start_date = %s) AND date >= %s))';
246 $params[] = '0000-00-00';
247 $params[] = $filters['date_from'];
248 $params[] = '0000-00-00';
249 $params[] = $filters['date_from'];
250 } else {
251 $where[] = 'date >= %s';
252 $params[] = $filters['date_from'];
253 }
254 }
255
256 if (!empty($filters['date_to']) && trim($filters['date_to']) !== '') {
257 if ($hasStartDate) {
258 // See note above re strict DATE comparisons and empty string.
259 $where[] = '((start_date IS NOT NULL AND start_date <> %s AND start_date <= %s) OR ((start_date IS NULL OR start_date = %s) AND date <= %s))';
260 $params[] = '0000-00-00';
261 $params[] = $filters['date_to'];
262 $params[] = '0000-00-00';
263 $params[] = $filters['date_to'];
264 } else {
265 $where[] = 'date <= %s';
266 $params[] = $filters['date_to'];
267 }
268 }
269
270 // Source filter
271 if (!empty($filters['source']) && $filters['source'] !== 'all') {
272 $where[] = 'source = %s';
273 $params[] = $filters['source'];
274 }
275
276 // Past/upcoming filter. Never exclude past dates when the caller is asking
277 // for the 'past' status — that combination is contradictory and returned
278 // nothing (the exact reason completed departures vanished from the Past tab).
279 if (isset($filters['include_past'])) {
280 if (!$filters['include_past'] && !$statusIsPast) {
281 $where[] = 'date >= CURDATE()';
282 }
283 }
284
285 // Past-only filter — for the "Past Departures" archive. Deliberately
286 // DATE-based (mirrors the end_date ?: start_date ?: date precedence used
287 // when marking departures past) rather than checking status = 'past':
288 // that stored status is only kept current by the daily cron, so relying
289 // on it made departures that had already taken place disappear entirely
290 // whenever the cron had not run. Date is the source of truth.
291 if (!empty($filters['past_only'])) {
292 // start_date / end_date are nullable DATE columns (never ''), so guard
293 // with IS NULL / IS NOT NULL — comparing a DATE column to '' errors
294 // under MySQL strict mode.
295 $where[] = "(
296 (end_date IS NOT NULL AND end_date < CURDATE())
297 OR (end_date IS NULL AND start_date IS NOT NULL AND start_date < CURDATE())
298 OR (end_date IS NULL AND start_date IS NULL AND date < CURDATE())
299 )";
300 }
301
302 $query = "SELECT * FROM `{$table}` WHERE " . implode(' AND ', $where);
303 $query .= " ORDER BY date ASC, time ASC";
304
305 if (!empty($filters['per_page'])) {
306 $perPage = (int) $filters['per_page'];
307 $page = max(1, (int) ($filters['page'] ?? 1));
308 $offset = ($page - 1) * $perPage;
309 $query .= " LIMIT %d OFFSET %d";
310 $params[] = $perPage;
311 $params[] = $offset;
312 }
313
314 $results = $this->wpdb->get_results(
315 $this->wpdb->prepare($query, ...$params),
316 ARRAY_A
317 );
318
319 return array_map(function ($row) {
320 return Departure::fromArray($row);
321 }, $results ?: []);
322 }
323
324 /**
325 * Find past departures by trip ID
326 */
327 public function findPastByTripId(int $tripId, array $filters = []): array
328 {
329 // Match by date, not the stored status column (see past_only in
330 // findByTripId) — a departure that has taken place must appear here even
331 // if the daily status cron has not yet marked it 'past'.
332 $filters['past_only'] = true;
333 $filters['include_past'] = true;
334 return $this->findByTripId($tripId, $filters);
335 }
336
337 /**
338 * Find upcoming departures by trip ID
339 */
340 public function findUpcomingByTripId(int $tripId, array $filters = []): array
341 {
342 $filters['include_past'] = false;
343 return $this->findByTripId($tripId, $filters);
344 }
345
346 /**
347 * Find departure by trip ID and date (backward compatibility - uses start_date)
348 */
349 public function findByTripIdAndDate(int $tripId, string $date, ?string $time = null): ?Departure
350 {
351 return $this->findByTripIdAndStartDate($tripId, $date, $time);
352 }
353
354 /**
355 * Count departures by trip ID
356 */
357 public function countByTripId(int $tripId, array $filters = []): int
358 {
359 $table = esc_sql($this->table);
360 $where = ['trip_id = %d'];
361 $params = [$tripId];
362
363 if (!empty($filters['status']) && $filters['status'] !== 'all') {
364 $where[] = 'status = %s';
365 $params[] = $filters['status'];
366 }
367
368 if (!empty($filters['date_from'])) {
369 $where[] = 'date >= %s';
370 $params[] = $filters['date_from'];
371 }
372
373 if (!empty($filters['date_to'])) {
374 $where[] = 'date <= %s';
375 $params[] = $filters['date_to'];
376 }
377
378 if (!empty($filters['source']) && $filters['source'] !== 'all') {
379 $where[] = 'source = %s';
380 $params[] = $filters['source'];
381 }
382
383 if (isset($filters['include_past']) && !$filters['include_past']) {
384 $where[] = 'date >= CURDATE()';
385 }
386
387 $query = "SELECT COUNT(*) FROM `{$table}` WHERE " . implode(' AND ', $where);
388
389 return (int) $this->wpdb->get_var($this->wpdb->prepare($query, ...$params));
390 }
391
392 /**
393 * Create a departure
394 */
395 public function create(array $data): int
396 {
397 $table = esc_sql($this->table);
398
399 // Check which columns exist in the table
400 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
401 $hasStartDate = in_array('start_date', $columns, true);
402 $hasEndDate = in_array('end_date', $columns, true);
403
404 // Handle start_date and end_date - support both old 'date' and new format
405 $startDate = !empty($data['start_date']) ? $data['start_date'] : ($data['date'] ?? '');
406 $endDate = $data['end_date'] ?? '';
407
408 $insertData = [
409 'trip_id' => (int) ($data['trip_id'] ?? 0),
410 'date' => sanitize_text_field($startDate), // Always include for backward compatibility
411 'time' => !empty($data['time']) ? sanitize_text_field($data['time']) : null,
412 'max_capacity' => (int) ($data['max_capacity'] ?? 0),
413 'booked_count' => (int) ($data['booked_count'] ?? 0),
414 'status' => sanitize_text_field($data['status'] ?? 'upcoming'),
415 'source' => sanitize_text_field($data['source'] ?? 'booking_created'),
416 'price_override' => !empty($data['price_override']) ? (float) $data['price_override'] : null,
417 'notes' => !empty($data['notes']) ? sanitize_textarea_field($data['notes']) : null,
418 'created_at' => current_time('mysql'),
419 'updated_at' => current_time('mysql'),
420 ];
421
422 // Only add start_date and end_date if columns exist
423 if ($hasStartDate) {
424 $insertData['start_date'] = sanitize_text_field($startDate);
425 }
426 if ($hasEndDate && !empty($endDate)) {
427 $insertData['end_date'] = sanitize_text_field($endDate);
428 }
429
430 // Only add total_revenue if column exists
431 $hasTotalRevenue = in_array('total_revenue', $columns, true);
432 if ($hasTotalRevenue) {
433 $insertData['total_revenue'] = !empty($data['total_revenue']) ? (float) $data['total_revenue'] : 0.00;
434 }
435
436 // Handle price_by_traveler_type as JSON
437 if (!empty($data['price_by_traveler_type'])) {
438 $insertData['price_by_traveler_type'] = is_array($data['price_by_traveler_type'])
439 ? wp_json_encode($data['price_by_traveler_type'])
440 : $data['price_by_traveler_type'];
441 } else {
442 $insertData['price_by_traveler_type'] = null;
443 }
444
445 // Calculate status if not provided
446 if (empty($data['status'])) {
447 $departure = Departure::fromArray($insertData);
448 $insertData['status'] = $departure->calculateStatus();
449 }
450
451 // Build format array dynamically based on what we're inserting
452 $formats = [];
453 foreach ($insertData as $key => $value) {
454 if (in_array($key, ['trip_id', 'max_capacity', 'booked_count'], true)) {
455 $formats[] = '%d';
456 } elseif (in_array($key, ['price_override'], true)) {
457 $formats[] = '%f';
458 } else {
459 $formats[] = '%s';
460 }
461 }
462
463 $this->wpdb->insert($table, $insertData, $formats);
464
465 return $this->wpdb->insert_id;
466 }
467
468 /**
469 * Update a departure
470 */
471 public function update(int $id, array $data): bool
472 {
473 $table = esc_sql($this->table);
474
475 // Check which columns exist
476 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
477 $hasStartDate = in_array('start_date', $columns, true);
478 $hasEndDate = in_array('end_date', $columns, true);
479
480 $updateData = [];
481
482 if (isset($data['trip_id'])) $updateData['trip_id'] = (int) $data['trip_id'];
483
484 // Handle date fields - support both old 'date' and new 'start_date'/'end_date'
485 if (isset($data['start_date'])) {
486 if ($hasStartDate) {
487 $updateData['start_date'] = sanitize_text_field($data['start_date']);
488 }
489 $updateData['date'] = sanitize_text_field($data['start_date']); // Always update date for backward compatibility
490 } elseif (isset($data['date'])) {
491 $updateData['date'] = sanitize_text_field($data['date']);
492 if ($hasStartDate) {
493 $updateData['start_date'] = $updateData['date']; // Sync start_date
494 }
495 }
496
497 if (isset($data['end_date']) && $hasEndDate) {
498 $updateData['end_date'] = sanitize_text_field($data['end_date']);
499 }
500 if (isset($data['time'])) $updateData['time'] = !empty($data['time']) ? sanitize_text_field($data['time']) : null;
501 if (isset($data['max_capacity'])) $updateData['max_capacity'] = (int) $data['max_capacity'];
502 if (isset($data['booked_count'])) $updateData['booked_count'] = (int) $data['booked_count'];
503 if (isset($data['status'])) $updateData['status'] = sanitize_text_field($data['status']);
504 if (isset($data['source'])) $updateData['source'] = sanitize_text_field($data['source']);
505 if (isset($data['price_override'])) $updateData['price_override'] = !empty($data['price_override']) ? (float) $data['price_override'] : null;
506
507 // Only update total_revenue if column exists
508 $hasTotalRevenue = in_array('total_revenue', $columns, true);
509 if (isset($data['total_revenue']) && $hasTotalRevenue) {
510 $updateData['total_revenue'] = !empty($data['total_revenue']) ? (float) $data['total_revenue'] : 0.00;
511 }
512
513 if (isset($data['notes'])) $updateData['notes'] = !empty($data['notes']) ? sanitize_textarea_field($data['notes']) : null;
514
515 if (isset($data['price_by_traveler_type'])) {
516 $updateData['price_by_traveler_type'] = is_array($data['price_by_traveler_type'])
517 ? wp_json_encode($data['price_by_traveler_type'])
518 : $data['price_by_traveler_type'];
519 }
520
521 $updateData['updated_at'] = current_time('mysql');
522
523 // Recalculate status if date or capacity changed
524 if (isset($updateData['start_date']) || isset($updateData['end_date']) || isset($updateData['date']) ||
525 isset($updateData['max_capacity']) || isset($updateData['booked_count'])) {
526 // Get current departure to merge with updates
527 $current = $this->findModel($id);
528 if ($current) {
529 $merged = array_merge($current->toArray(), $updateData);
530 $departure = Departure::fromArray($merged);
531 $updateData['status'] = $departure->calculateStatus();
532 }
533 }
534
535 if (empty($updateData)) {
536 return false;
537 }
538
539 $formats = [];
540 foreach ($updateData as $value) {
541 if (is_int($value)) {
542 $formats[] = '%d';
543 } elseif (is_float($value)) {
544 $formats[] = '%f';
545 } else {
546 $formats[] = '%s';
547 }
548 }
549
550 return (bool) $this->wpdb->update(
551 $table,
552 $updateData,
553 ['id' => $id],
554 $formats,
555 ['%d']
556 );
557 }
558
559 /**
560 * Atomically increment booked count, refusing the write when it
561 * would exceed max_capacity.
562 *
563 * The capacity guard lives in the SQL WHERE clause — not in PHP —
564 * so concurrent writers can't both read "we have room" and then
565 * both succeed. Each writer's UPDATE either updates 1 row (the
566 * reservation succeeded; capacity was decremented atomically) or
567 * 0 rows (the seats were taken between read and write; the caller
568 * should treat this as "departure full").
569 *
570 * `max_capacity = 0` or NULL means "unlimited" — the guard
571 * intentionally allows unlimited writes in that case.
572 *
573 * Returns true only when 1 row was actually updated. Previous
574 * behaviour returned true unconditionally, which created a
575 * check-then-act overbooking race in `DepartureService::
576 * incrementBookedCount()`.
577 */
578 public function incrementBookedCount(int $id, int $amount = 1, bool $force = false): bool
579 {
580 if ($amount <= 0 || $id <= 0) return false;
581
582 $table = esc_sql($this->table);
583
584 // The capacity guard (`booked_count + %d <= max_capacity`) is
585 // the right default for direct bookings — it stops the website
586 // checkout from overselling a seat that's no longer there.
587 //
588 // For external-channel bookings (Viator / GetYourGuide / any
589 // OTA webhook) the seat has ALREADY been sold on the OTA. We
590 // MUST record the booking locally even if our view of capacity
591 // says "no room left" — refusing to record would just hide the
592 // oversell from the operator and make reconciliation impossible.
593 // Callers that own that case pass `$force = true` and the
594 // capacity clause is dropped from the WHERE.
595 $sql = "UPDATE `{$table}`
596 SET booked_count = booked_count + %d,
597 updated_at = %s
598 WHERE id = %d";
599 $args = [$amount, current_time('mysql'), $id];
600
601 if (!$force) {
602 $sql .= "
603 AND (max_capacity IS NULL
604 OR max_capacity = 0
605 OR booked_count + %d <= max_capacity)";
606 $args[] = $amount;
607 }
608
609 $result = $this->wpdb->query($this->wpdb->prepare($sql, $args));
610
611 if ($result === false) return false; // SQL error
612 if ((int) $result === 0) return false; // capacity guard rejected the write (only possible when !$force)
613
614 // Recalculate status only when the reservation actually landed.
615 $departure = $this->findModel($id);
616 if ($departure) {
617 $this->update($id, ['status' => $departure->calculateStatus()]);
618 }
619
620 return true;
621 }
622
623 /**
624 * Decrement booked count
625 */
626 public function decrementBookedCount(int $id, int $amount = 1): bool
627 {
628 $table = esc_sql($this->table);
629
630 $this->wpdb->query($this->wpdb->prepare(
631 "UPDATE `{$table}`
632 SET booked_count = GREATEST(0, booked_count - %d),
633 updated_at = %s
634 WHERE id = %d",
635 $amount,
636 current_time('mysql'),
637 $id
638 ));
639
640 // Recalculate status
641 $departure = $this->findModel($id);
642 if ($departure) {
643 $this->update($id, ['status' => $departure->calculateStatus()]);
644 }
645
646 return true;
647 }
648
649 /**
650 * Delete a departure.
651 *
652 * The booking-level policy lives in DepartureService::delete(), which is the
653 * only caller — this performs the row removal itself.
654 */
655 public function delete(int $id): bool
656 {
657 $departure = $this->findModel($id);
658
659 if (!$departure) {
660 return false;
661 }
662
663 // This previously required source === 'recurring_generated', a value the
664 // plugin never writes (departures are `booking_created` or `manual`, see
665 // Departure::$source), so the guard could never pass and every departure
666 // was undeletable.
667 $table = esc_sql($this->table);
668
669 return (bool) $this->wpdb->delete($table, ['id' => $id], ['%d']);
670 }
671
672 /**
673 * Recalculate status for all departures (for cron job)
674 */
675 public function recalculateAllStatuses(): int
676 {
677 $table = esc_sql($this->table);
678 $today = date('Y-m-d');
679
680 // The effective date is end_date ?: start_date ?: date. start_date and
681 // end_date are nullable DATE columns (never ''), so guard with IS NULL /
682 // IS NOT NULL — comparing a DATE column to '' errors under MySQL strict
683 // mode, which previously made this whole recalculation fail silently.
684 $pastCond = "(
685 (end_date IS NOT NULL AND end_date < %s) OR
686 (end_date IS NULL AND start_date IS NOT NULL AND start_date < %s) OR
687 (end_date IS NULL AND start_date IS NULL AND date < %s)
688 )";
689 $futureCond = "(
690 (end_date IS NOT NULL AND end_date >= CURDATE()) OR
691 (end_date IS NULL AND start_date IS NOT NULL AND start_date >= CURDATE()) OR
692 (end_date IS NULL AND start_date IS NULL AND date >= CURDATE())
693 )";
694
695 // Update past departures.
696 $this->wpdb->query($this->wpdb->prepare(
697 "UPDATE `{$table}`
698 SET status = 'past', updated_at = %s
699 WHERE {$pastCond}
700 AND status != 'cancelled'",
701 current_time('mysql'),
702 $today,
703 $today,
704 $today
705 ));
706
707 // Update full departures - future dates only.
708 $this->wpdb->query(
709 "UPDATE `{$table}`
710 SET status = 'full', updated_at = NOW()
711 WHERE booked_count >= max_capacity
712 AND max_capacity > 0
713 AND {$futureCond}
714 AND status NOT IN ('cancelled', 'past')"
715 );
716
717 // Update upcoming departures - future dates only.
718 $this->wpdb->query(
719 "UPDATE `{$table}`
720 SET status = 'upcoming', updated_at = NOW()
721 WHERE {$futureCond}
722 AND booked_count < max_capacity
723 AND status NOT IN ('cancelled', 'past', 'full')"
724 );
725
726 return $this->wpdb->rows_affected;
727 }
728
729 /**
730 * Check if table supports soft delete
731 */
732 protected function hasSoftDelete(): bool
733 {
734 return false;
735 }
736 }
737
738