← All changes
|
src/EventTickets/Repositories/EventTicketRepository.php
+48
-4
4.16.7
→
4.18.0
View file →
| @@ -8,8 +8,9 @@ | ||
| 8 | 8 | use Give\EventTickets\Models\EventTicket; |
| 9 | 9 | use Give\Framework\Database\DB; |
| 10 | 10 | use Give\Framework\Exceptions\Primitives\Exception; |
| 11 | 11 | use Give\Framework\Exceptions\Primitives\InvalidArgumentException; |
| 12 | +use Give\Framework\Exceptions\Primitives\RuntimeException; | |
| 12 | 13 | use Give\Framework\Models\ModelQueryBuilder; |
| 13 | 14 | use Give\Framework\Support\Facades\DateTime\Temporal; |
| 14 | 15 | use Give\Framework\Support\ValueObjects\Money; |
| 15 | 16 | use Give\Helpers\Hooks; |
| @@ -58,12 +59,14 @@ | ||
| 58 | 59 | ->where('id', $id); |
| 59 | 60 | } |
| 60 | 61 | |
| 61 | 62 | /** |
| 63 | + * @since 4.18.0 Keep the original error as the previous exception when the write fails. | |
| 64 | + * @since 4.16.8.1 Enforce the ticket type's remaining capacity atomically with the insert (locking the ticket type row and re-counting under that lock), closing a race that let concurrent purchases jointly oversell it. | |
| 62 | 65 | * @since 3.20.0 Add "amount" column to the insert statement |
| 63 | 66 | * @since 3.6.0 |
| 64 | 67 | * |
| 65 | - * @throws Exception|InvalidArgumentException | |
| 68 | + * @throws Exception|InvalidArgumentException|RuntimeException | |
| 66 | 69 | */ |
| 67 | 70 | public function insert(EventTicket $eventTicket) |
| 68 | 71 | { |
| 69 | 72 | if (!$this->isFeatureActive()) { |
| @@ -77,8 +80,14 @@ | ||
| 77 | 80 | $createdDateTime = Temporal::withoutMicroseconds($eventTicket->createdAt ?: Temporal::getCurrentDateTime()); |
| 78 | 81 | |
| 79 | 82 | DB::query('START TRANSACTION'); |
| 80 | 83 | |
| 84 | + if (!$this->hasRemainingCapacity($eventTicket)) { | |
| 85 | + DB::query('ROLLBACK'); | |
| 86 | + | |
| 87 | + throw new RuntimeException('Ticket type has no remaining capacity'); | |
| 88 | + } | |
| 89 | + | |
| 81 | 90 | try { |
| 82 | 91 | DB::table('give_event_tickets') |
| 83 | 92 | ->insert([ |
| 84 | 93 | 'event_id' => $eventTicket->eventId, |
| @@ -94,9 +103,9 @@ | ||
| 94 | 103 | DB::query('ROLLBACK'); |
| 95 | 104 | |
| 96 | 105 | Log::error('Failed creating an event ticket', compact('eventTicket')); |
| 97 | 106 | |
| 98 | - throw new $exception('Failed creating an event ticket'); | |
| 107 | + throw new Exception('Failed creating an event ticket', 0, $exception); | |
| 99 | 108 | } |
| 100 | 109 | |
| 101 | 110 | $eventTicket->id = $eventTicketId; |
| 102 | 111 | $eventTicket->createdAt = $createdDateTime; |
| @@ -107,8 +116,42 @@ | ||
| 107 | 116 | Hooks::doAction('givewp_events_event_ticket_created', $eventTicket); |
| 108 | 117 | } |
| 109 | 118 | |
| 110 | 119 | /** |
| 120 | + * Locks the ticket type's row and checks its remaining capacity against a fresh ticket count taken | |
| 121 | + * under that lock. Must only be called after DB::query('START TRANSACTION') — the lock it takes is | |
| 122 | + * what makes the check-then-insert in insert() atomic across concurrent requests for the same | |
| 123 | + * ticket type; called on its own, outside a transaction, it would just be another stale read. | |
| 124 | + * | |
| 125 | + * @since 4.16.8.1 | |
| 126 | + */ | |
| 127 | + private function hasRemainingCapacity(EventTicket $eventTicket): bool | |
| 128 | + { | |
| 129 | + global $wpdb; | |
| 130 | + | |
| 131 | + $capacity = DB::get_var( | |
| 132 | + DB::prepare( | |
| 133 | + "SELECT capacity FROM {$wpdb->give_event_ticket_types} WHERE id = %d FOR UPDATE", | |
| 134 | + $eventTicket->ticketTypeId | |
| 135 | + ) | |
| 136 | + ); | |
| 137 | + | |
| 138 | + if ($capacity === null) { | |
| 139 | + return false; | |
| 140 | + } | |
| 141 | + | |
| 142 | + $ticketCount = (int)DB::get_var( | |
| 143 | + DB::prepare( | |
| 144 | + "SELECT COUNT(*) FROM {$wpdb->give_event_tickets} WHERE ticket_type_id = %d", | |
| 145 | + $eventTicket->ticketTypeId | |
| 146 | + ) | |
| 147 | + ); | |
| 148 | + | |
| 149 | + return $ticketCount < (int)$capacity; | |
| 150 | + } | |
| 151 | + | |
| 152 | + /** | |
| 153 | + * @since 4.18.0 Keep the original error as the previous exception when the write fails. | |
| 111 | 154 | * @since 3.20.0 Add "amount" column to the update statement |
| 112 | 155 | * @since 3.6.0 |
| 113 | 156 | * |
| 114 | 157 | * @throws Exception|InvalidArgumentException |
| @@ -142,9 +185,9 @@ | ||
| 142 | 185 | DB::query('ROLLBACK'); |
| 143 | 186 | |
| 144 | 187 | Log::error('Failed updating an event ticket', compact('eventTicket')); |
| 145 | 188 | |
| 146 | - throw new $exception('Failed updating an event ticket'); | |
| 189 | + throw new Exception('Failed updating an event ticket', 0, $exception); | |
| 147 | 190 | } |
| 148 | 191 | |
| 149 | 192 | $eventTicket->updatedAt = $updatedDateTime; |
| 150 | 193 | |
| @@ -153,8 +196,9 @@ | ||
| 153 | 196 | Hooks::doAction('givewp_events_event_ticket_updated', $eventTicket); |
| 154 | 197 | } |
| 155 | 198 | |
| 156 | 199 | /** |
| 200 | + * @since 4.18.0 Keep the original error as the previous exception when the write fails. | |
| 157 | 201 | * @since 3.6.0 |
| 158 | 202 | * |
| 159 | 203 | * @throws Exception |
| 160 | 204 | */ |
| @@ -176,9 +220,9 @@ | ||
| 176 | 220 | DB::query('ROLLBACK'); |
| 177 | 221 | |
| 178 | 222 | Log::error('Failed deleting an event ticket', compact('eventTicket')); |
| 179 | 223 | |
| 180 | - throw new $exception('Failed deleting an event ticket'); | |
| 224 | + throw new Exception('Failed deleting an event ticket', 0, $exception); | |
| 181 | 225 | } |
| 182 | 226 | |
| 183 | 227 | DB::query('COMMIT'); |
| 184 | 228 | |