PluginProbe
GiveWP – Donation Plugin and Fundraising Platform / 4.16.9
GiveWP – Donation Plugin and Fundraising Platform v4.16.9
4.16.9 4.16.8.1 4.16.8 4.16.7.2 4.16.7.1 4.16.7 4.16.6.1 4.16.6 4.16.5.1 4.16.5 4.16.4 4.16.3 4.16.2 4.16.1 4.16.0 4.15.5 4.15.4 4.15.3 4.15.2 4.15.1 4.15.0 2.3.0 2.3.1 2.3.2 2.30.0 All 255 releases
give / src / EventTickets / Repositories / EventTicketRepository.php

EventTicketRepository.php in GiveWP – Donation Plugin and Fundraising Platform 4.16.9, at src/EventTickets/Repositories/EventTicketRepository.php

369 lines 11.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Give\EventTickets\Repositories;
4
5 use Give\BetaFeatures\Facades\FeatureFlag;
6 use Give\Donations\Models\Donation;
7 use Give\Donations\ValueObjects\DonationMetaKeys;
8 use Give\EventTickets\Models\EventTicket;
9 use Give\Framework\Database\DB;
10 use Give\Framework\Exceptions\Primitives\Exception;
11 use Give\Framework\Exceptions\Primitives\InvalidArgumentException;
12 use Give\Framework\Exceptions\Primitives\RuntimeException;
13 use Give\Framework\Models\ModelQueryBuilder;
14 use Give\Framework\Support\Facades\DateTime\Temporal;
15 use Give\Framework\Support\ValueObjects\Money;
16 use Give\Helpers\Hooks;
17 use Give\Helpers\Table;
18 use Give\Log\Log;
19
20 /**
21 * @since 3.6.0
22 */
23 class EventTicketRepository
24 {
25
26 /**
27 * @since 3.20.0 Add "amount" column to the properties array
28 * @since 3.6.0
29 *
30 * @var string[]
31 */
32 private $requiredProperties = [
33 'eventId',
34 'ticketTypeId',
35 'donationId',
36 'amount',
37 ];
38
39 /**
40 * @since 3.6.0
41 */
42 public function getById(int $id): ?EventTicket
43 {
44 if (!$this->isFeatureActive()) {
45 return null;
46 }
47
48 return $this->prepareQuery()
49 ->where('id', $id)
50 ->get();
51 }
52
53 /**
54 * @since 3.6.0
55 */
56 public function queryById(int $id): ModelQueryBuilder
57 {
58 return $this->prepareQuery()
59 ->where('id', $id);
60 }
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.
64 * @since 3.20.0 Add "amount" column to the insert statement
65 * @since 3.6.0
66 *
67 * @throws Exception|InvalidArgumentException|RuntimeException
68 */
69 public function insert(EventTicket $eventTicket)
70 {
71 if (!$this->isFeatureActive()) {
72 throw new Exception('Event tickets feature is not active');
73 }
74
75 $this->validate($eventTicket);
76
77 Hooks::doAction('givewp_events_event_ticket_creating', $eventTicket);
78
79 $createdDateTime = Temporal::withoutMicroseconds($eventTicket->createdAt ?: Temporal::getCurrentDateTime());
80
81 DB::query('START TRANSACTION');
82
83 if (!$this->hasRemainingCapacity($eventTicket)) {
84 DB::query('ROLLBACK');
85
86 throw new RuntimeException('Ticket type has no remaining capacity');
87 }
88
89 try {
90 DB::table('give_event_tickets')
91 ->insert([
92 'event_id' => $eventTicket->eventId,
93 'ticket_type_id' => $eventTicket->ticketTypeId,
94 'donation_id' => $eventTicket->donationId,
95 'amount' => $eventTicket->amount->formatToMinorAmount(),
96 'created_at' => $createdDateTime->format('Y-m-d H:i:s'),
97 'updated_at' => $createdDateTime->format('Y-m-d H:i:s'),
98 ]);
99
100 $eventTicketId = DB::last_insert_id();
101 } catch (Exception $exception) {
102 DB::query('ROLLBACK');
103
104 Log::error('Failed creating an event ticket', compact('eventTicket'));
105
106 throw new $exception('Failed creating an event ticket');
107 }
108
109 $eventTicket->id = $eventTicketId;
110 $eventTicket->createdAt = $createdDateTime;
111 $eventTicket->updatedAt = $createdDateTime;
112
113 DB::query('COMMIT');
114
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;
149 }
150
151 /**
152 * @since 3.20.0 Add "amount" column to the update statement
153 * @since 3.6.0
154 *
155 * @throws Exception|InvalidArgumentException
156 */
157 public function update(EventTicket $eventTicket)
158 {
159 if (!$this->isFeatureActive()) {
160 throw new Exception('Event tickets feature is not active');
161 }
162
163 $this->validate($eventTicket);
164
165 Hooks::doAction('givewp_events_event_ticket_updating', $eventTicket);
166
167 $updatedDateTime = Temporal::withoutMicroseconds(Temporal::getCurrentDateTime());
168
169 DB::query('START TRANSACTION');
170
171 try {
172
173 DB::table('give_event_tickets')
174 ->where('id', $eventTicket->id)
175 ->update([
176 'event_id' => $eventTicket->eventId,
177 'ticket_type_id' => $eventTicket->ticketTypeId,
178 'donation_id' => $eventTicket->donationId,
179 'amount' => $eventTicket->amount->formatToMinorAmount(),
180 'updated_at' => $updatedDateTime->format('Y-m-d H:i:s'),
181 ]);
182 } catch (Exception $exception) {
183 DB::query('ROLLBACK');
184
185 Log::error('Failed updating an event ticket', compact('eventTicket'));
186
187 throw new $exception('Failed updating an event ticket');
188 }
189
190 $eventTicket->updatedAt = $updatedDateTime;
191
192 DB::query('COMMIT');
193
194 Hooks::doAction('givewp_events_event_ticket_updated', $eventTicket);
195 }
196
197 /**
198 * @since 3.6.0
199 *
200 * @throws Exception
201 */
202 public function delete(EventTicket $eventTicket): bool
203 {
204 if (!$this->isFeatureActive()) {
205 throw new Exception('Event tickets feature is not active');
206 }
207
208 DB::query('START TRANSACTION');
209
210 Hooks::doAction('givewp_events_event_ticket_deleting', $eventTicket);
211
212 try {
213 DB::table('give_event_tickets')
214 ->where('id', $eventTicket->id)
215 ->delete();
216 } catch (Exception $exception) {
217 DB::query('ROLLBACK');
218
219 Log::error('Failed deleting an event ticket', compact('eventTicket'));
220
221 throw new $exception('Failed deleting an event ticket');
222 }
223
224 DB::query('COMMIT');
225
226 Hooks::doAction('givewp_events_event_ticket_deleted', $eventTicket);
227
228 return true;
229 }
230
231 /**
232 * Check if the event tickets feature is active and table exists
233 *
234 * @since 4.6.0
235 * @return bool
236 */
237 private function isFeatureActive(): bool
238 {
239 return FeatureFlag::eventTickets() && $this->tableExists('give_event_tickets');
240 }
241
242 /**
243 * @since 3.6.0
244 */
245 private function validate(EventTicket $eventTicket): void
246 {
247 foreach ($this->requiredProperties as $key) {
248 if (!isset($eventTicket->$key)) {
249 throw new InvalidArgumentException("'$key' is required.");
250 }
251 }
252 }
253
254 /**
255 * @since 4.6.0 Add support for feature flag when disabled and include donation currency
256 * @since 3.20.0 Add "amount" column to the select statement
257 * @since 3.6.0
258 * @return ModelQueryBuilder<EventTicket>
259 */
260 public function prepareQuery(): ModelQueryBuilder
261 {
262 $builder = new ModelQueryBuilder(EventTicket::class);
263
264 if (!$this->isFeatureActive()) {
265 // Return a query builder that safely returns empty results
266 // Use a subquery that will never return results but handles all possible column references
267 return $builder->from(
268 DB::raw('(SELECT NULL as id, NULL as event_id, NULL as ticket_type_id, NULL as donation_id, NULL as amount, NULL as created_at, NULL as updated_at, NULL as currency WHERE 1 = 0)'),
269 'tickets'
270 );
271 }
272
273 return $builder->from('give_event_tickets', 'tickets')
274 ->select(
275 ['tickets.id', 'id'],
276 ['tickets.event_id', 'event_id'],
277 ['tickets.ticket_type_id', 'ticket_type_id'],
278 ['tickets.amount', 'amount'],
279 ['tickets.created_at', 'created_at'],
280 ['tickets.updated_at', 'updated_at'],
281 )
282 ->selectRaw("tickets.donation_id as donation_id")
283 ->attachMeta(
284 'give_donationmeta',
285 'tickets.donation_id',
286 'donation_id',
287 [DonationMetaKeys::CURRENCY, 'currency']
288 );
289 }
290
291 /**
292 * Check if a database table exists
293 *
294 * @since 4.6.0
295 */
296 private function tableExists(string $tableName): bool
297 {
298 global $wpdb;
299
300 $prefixedTableName = $wpdb->prefix . $tableName;
301 $query = $wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($prefixedTableName));
302
303 return (bool) $wpdb->get_var($query);
304 }
305
306 /**
307 * @since 3.6.0
308 */
309 public function queryByEventId(int $eventId): ModelQueryBuilder
310 {
311 return $this->prepareQuery()
312 ->where('tickets.event_id', $eventId);
313 }
314
315 /**
316 * @since 3.6.0
317 */
318 public function queryByTicketTypeId(int $ticketTypeId): ModelQueryBuilder
319 {
320 return $this->prepareQuery()
321 ->where('tickets.ticket_type_id', $ticketTypeId);
322 }
323
324 /**
325 * @since 3.6.0
326 *
327 * @param int $donationId
328 *
329 * @return ModelQueryBuilder
330 */
331 public function queryByDonationId(int $donationId): ModelQueryBuilder
332 {
333 return $this->prepareQuery()
334 ->where('tickets.donation_id', $donationId);
335 }
336
337 /**
338 * @since 4.6.0 Ensure the currency is the same as the donation amount currency
339 * @since 3.20.0 Refactored to use event ticket amount instead of ticket type price
340 * @since 3.6.0
341 */
342 public function getTotalByDonation(Donation $donation): Money
343 {
344 $eventTickets = $this->queryByDonationId($donation->id)->getAll() ?? [];
345
346 return array_reduce($eventTickets, static function (Money $carry, EventTicket $eventTicket) {
347 return $carry->add($eventTicket->amount);
348 }, new Money(0, $donation->amount->getCurrency()));
349 }
350
351 /**
352 * @since 4.6.0
353 */
354 public function getEventTicketDetails(Donation $donation): array
355 {
356 $details = [];
357 $eventTickets = $this->queryByDonationId($donation->id)->getAll() ?? [];
358
359 foreach ($eventTickets as $eventTicket) {
360 $details[] = array_merge($eventTicket->toArray(), [
361 'event' => $eventTicket->event->toArray(),
362 'ticketType' => $eventTicket->ticketType->toArray(),
363 ]);
364 }
365
366 return $details;
367 }
368 }
369