← All changes
|
src/EventTickets/Repositories/EventTicketRepository.php
+42
-1
4.16.6
→
4.16.9
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,13 @@ | ||
| 58 | 59 | ->where('id', $id); |
| 59 | 60 | } |
| 60 | 61 | |
| 61 | 62 | /** |
| 63 | + * @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 | 64 | * @since 3.20.0 Add "amount" column to the insert statement |
| 63 | 65 | * @since 3.6.0 |
| 64 | 66 | * |
| 65 | - * @throws Exception|InvalidArgumentException | |
| 67 | + * @throws Exception|InvalidArgumentException|RuntimeException | |
| 66 | 68 | */ |
| 67 | 69 | public function insert(EventTicket $eventTicket) |
| 68 | 70 | { |
| 69 | 71 | if (!$this->isFeatureActive()) { |
| @@ -77,8 +79,14 @@ | ||
| 77 | 79 | $createdDateTime = Temporal::withoutMicroseconds($eventTicket->createdAt ?: Temporal::getCurrentDateTime()); |
| 78 | 80 | |
| 79 | 81 | DB::query('START TRANSACTION'); |
| 80 | 82 | |
| 83 | + if (!$this->hasRemainingCapacity($eventTicket)) { | |
| 84 | + DB::query('ROLLBACK'); | |
| 85 | + | |
| 86 | + throw new RuntimeException('Ticket type has no remaining capacity'); | |
| 87 | + } | |
| 88 | + | |
| 81 | 89 | try { |
| 82 | 90 | DB::table('give_event_tickets') |
| 83 | 91 | ->insert([ |
| 84 | 92 | 'event_id' => $eventTicket->eventId, |
| @@ -104,8 +112,41 @@ | ||
| 104 | 112 | |
| 105 | 113 | DB::query('COMMIT'); |
| 106 | 114 | |
| 107 | 115 | Hooks::doAction('givewp_events_event_ticket_created', $eventTicket); |
| 116 | + } | |
| 117 | + | |
| 118 | + /** | |
| 119 | + * Locks the ticket type's row and checks its remaining capacity against a fresh ticket count taken | |
| 120 | + * under that lock. Must only be called after DB::query('START TRANSACTION') — the lock it takes is | |
| 121 | + * what makes the check-then-insert in insert() atomic across concurrent requests for the same | |
| 122 | + * ticket type; called on its own, outside a transaction, it would just be another stale read. | |
| 123 | + * | |
| 124 | + * @since 4.16.8.1 | |
| 125 | + */ | |
| 126 | + private function hasRemainingCapacity(EventTicket $eventTicket): bool | |
| 127 | + { | |
| 128 | + global $wpdb; | |
| 129 | + | |
| 130 | + $capacity = DB::get_var( | |
| 131 | + DB::prepare( | |
| 132 | + "SELECT capacity FROM {$wpdb->give_event_ticket_types} WHERE id = %d FOR UPDATE", | |
| 133 | + $eventTicket->ticketTypeId | |
| 134 | + ) | |
| 135 | + ); | |
| 136 | + | |
| 137 | + if ($capacity === null) { | |
| 138 | + return false; | |
| 139 | + } | |
| 140 | + | |
| 141 | + $ticketCount = (int)DB::get_var( | |
| 142 | + DB::prepare( | |
| 143 | + "SELECT COUNT(*) FROM {$wpdb->give_event_tickets} WHERE ticket_type_id = %d", | |
| 144 | + $eventTicket->ticketTypeId | |
| 145 | + ) | |
| 146 | + ); | |
| 147 | + | |
| 148 | + return $ticketCount < (int)$capacity; | |
| 108 | 149 | } |
| 109 | 150 | |
| 110 | 151 | /** |
| 111 | 152 | * @since 3.20.0 Add "amount" column to the update statement |