PluginProbe
GiveWP – Donation Plugin and Fundraising Platform / 4.18.0
GiveWP – Donation Plugin and Fundraising Platform v4.18.0
4.18.0 4.17.0 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 All 257 releases
give / src / EventTickets / Repositories / EventTicketRepository.php

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

372 lines 11.4 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.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.
65 * @since 3.20.0 Add "amount" column to the insert statement
66 * @since 3.6.0
67 *
68 * @throws Exception|InvalidArgumentException|RuntimeException
69 */
70 public function insert(EventTicket $eventTicket)
71 {
72 if (!$this->isFeatureActive()) {
73 throw new Exception('Event tickets feature is not active');
74 }
75
76 $this->validate($eventTicket);
77
78 Hooks::doAction('givewp_events_event_ticket_creating', $eventTicket);
79
80 $createdDateTime = Temporal::withoutMicroseconds($eventTicket->createdAt ?: Temporal::getCurrentDateTime());
81
82 DB::query('START TRANSACTION');
83
84 if (!$this->hasRemainingCapacity($eventTicket)) {
85 DB::query('ROLLBACK');
86
87 throw new RuntimeException('Ticket type has no remaining capacity');
88 }
89
90 try {
91 DB::table('give_event_tickets')
92 ->insert([
93 'event_id' => $eventTicket->eventId,
94 'ticket_type_id' => $eventTicket->ticketTypeId,
95 'donation_id' => $eventTicket->donationId,
96 'amount' => $eventTicket->amount->formatToMinorAmount(),
97 'created_at' => $createdDateTime->format('Y-m-d H:i:s'),
98 'updated_at' => $createdDateTime->format('Y-m-d H:i:s'),
99 ]);
100
101 $eventTicketId = DB::last_insert_id();
102 } catch (Exception $exception) {
103 DB::query('ROLLBACK');
104
105 Log::error('Failed creating an event ticket', compact('eventTicket'));
106
107 throw new Exception('Failed creating an event ticket', 0, $exception);
108 }
109
110 $eventTicket->id = $eventTicketId;
111 $eventTicket->createdAt = $createdDateTime;
112 $eventTicket->updatedAt = $createdDateTime;
113
114 DB::query('COMMIT');
115
116 Hooks::doAction('givewp_events_event_ticket_created', $eventTicket);
117 }
118
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.
154 * @since 3.20.0 Add "amount" column to the update statement
155 * @since 3.6.0
156 *
157 * @throws Exception|InvalidArgumentException
158 */
159 public function update(EventTicket $eventTicket)
160 {
161 if (!$this->isFeatureActive()) {
162 throw new Exception('Event tickets feature is not active');
163 }
164
165 $this->validate($eventTicket);
166
167 Hooks::doAction('givewp_events_event_ticket_updating', $eventTicket);
168
169 $updatedDateTime = Temporal::withoutMicroseconds(Temporal::getCurrentDateTime());
170
171 DB::query('START TRANSACTION');
172
173 try {
174
175 DB::table('give_event_tickets')
176 ->where('id', $eventTicket->id)
177 ->update([
178 'event_id' => $eventTicket->eventId,
179 'ticket_type_id' => $eventTicket->ticketTypeId,
180 'donation_id' => $eventTicket->donationId,
181 'amount' => $eventTicket->amount->formatToMinorAmount(),
182 'updated_at' => $updatedDateTime->format('Y-m-d H:i:s'),
183 ]);
184 } catch (Exception $exception) {
185 DB::query('ROLLBACK');
186
187 Log::error('Failed updating an event ticket', compact('eventTicket'));
188
189 throw new Exception('Failed updating an event ticket', 0, $exception);
190 }
191
192 $eventTicket->updatedAt = $updatedDateTime;
193
194 DB::query('COMMIT');
195
196 Hooks::doAction('givewp_events_event_ticket_updated', $eventTicket);
197 }
198
199 /**
200 * @since 4.18.0 Keep the original error as the previous exception when the write fails.
201 * @since 3.6.0
202 *
203 * @throws Exception
204 */
205 public function delete(EventTicket $eventTicket): bool
206 {
207 if (!$this->isFeatureActive()) {
208 throw new Exception('Event tickets feature is not active');
209 }
210
211 DB::query('START TRANSACTION');
212
213 Hooks::doAction('givewp_events_event_ticket_deleting', $eventTicket);
214
215 try {
216 DB::table('give_event_tickets')
217 ->where('id', $eventTicket->id)
218 ->delete();
219 } catch (Exception $exception) {
220 DB::query('ROLLBACK');
221
222 Log::error('Failed deleting an event ticket', compact('eventTicket'));
223
224 throw new Exception('Failed deleting an event ticket', 0, $exception);
225 }
226
227 DB::query('COMMIT');
228
229 Hooks::doAction('givewp_events_event_ticket_deleted', $eventTicket);
230
231 return true;
232 }
233
234 /**
235 * Check if the event tickets feature is active and table exists
236 *
237 * @since 4.6.0
238 * @return bool
239 */
240 private function isFeatureActive(): bool
241 {
242 return FeatureFlag::eventTickets() && $this->tableExists('give_event_tickets');
243 }
244
245 /**
246 * @since 3.6.0
247 */
248 private function validate(EventTicket $eventTicket): void
249 {
250 foreach ($this->requiredProperties as $key) {
251 if (!isset($eventTicket->$key)) {
252 throw new InvalidArgumentException("'$key' is required.");
253 }
254 }
255 }
256
257 /**
258 * @since 4.6.0 Add support for feature flag when disabled and include donation currency
259 * @since 3.20.0 Add "amount" column to the select statement
260 * @since 3.6.0
261 * @return ModelQueryBuilder<EventTicket>
262 */
263 public function prepareQuery(): ModelQueryBuilder
264 {
265 $builder = new ModelQueryBuilder(EventTicket::class);
266
267 if (!$this->isFeatureActive()) {
268 // Return a query builder that safely returns empty results
269 // Use a subquery that will never return results but handles all possible column references
270 return $builder->from(
271 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)'),
272 'tickets'
273 );
274 }
275
276 return $builder->from('give_event_tickets', 'tickets')
277 ->select(
278 ['tickets.id', 'id'],
279 ['tickets.event_id', 'event_id'],
280 ['tickets.ticket_type_id', 'ticket_type_id'],
281 ['tickets.amount', 'amount'],
282 ['tickets.created_at', 'created_at'],
283 ['tickets.updated_at', 'updated_at'],
284 )
285 ->selectRaw("tickets.donation_id as donation_id")
286 ->attachMeta(
287 'give_donationmeta',
288 'tickets.donation_id',
289 'donation_id',
290 [DonationMetaKeys::CURRENCY, 'currency']
291 );
292 }
293
294 /**
295 * Check if a database table exists
296 *
297 * @since 4.6.0
298 */
299 private function tableExists(string $tableName): bool
300 {
301 global $wpdb;
302
303 $prefixedTableName = $wpdb->prefix . $tableName;
304 $query = $wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($prefixedTableName));
305
306 return (bool) $wpdb->get_var($query);
307 }
308
309 /**
310 * @since 3.6.0
311 */
312 public function queryByEventId(int $eventId): ModelQueryBuilder
313 {
314 return $this->prepareQuery()
315 ->where('tickets.event_id', $eventId);
316 }
317
318 /**
319 * @since 3.6.0
320 */
321 public function queryByTicketTypeId(int $ticketTypeId): ModelQueryBuilder
322 {
323 return $this->prepareQuery()
324 ->where('tickets.ticket_type_id', $ticketTypeId);
325 }
326
327 /**
328 * @since 3.6.0
329 *
330 * @param int $donationId
331 *
332 * @return ModelQueryBuilder
333 */
334 public function queryByDonationId(int $donationId): ModelQueryBuilder
335 {
336 return $this->prepareQuery()
337 ->where('tickets.donation_id', $donationId);
338 }
339
340 /**
341 * @since 4.6.0 Ensure the currency is the same as the donation amount currency
342 * @since 3.20.0 Refactored to use event ticket amount instead of ticket type price
343 * @since 3.6.0
344 */
345 public function getTotalByDonation(Donation $donation): Money
346 {
347 $eventTickets = $this->queryByDonationId($donation->id)->getAll() ?? [];
348
349 return array_reduce($eventTickets, static function (Money $carry, EventTicket $eventTicket) {
350 return $carry->add($eventTicket->amount);
351 }, new Money(0, $donation->amount->getCurrency()));
352 }
353
354 /**
355 * @since 4.6.0
356 */
357 public function getEventTicketDetails(Donation $donation): array
358 {
359 $details = [];
360 $eventTickets = $this->queryByDonationId($donation->id)->getAll() ?? [];
361
362 foreach ($eventTickets as $eventTicket) {
363 $details[] = array_merge($eventTicket->toArray(), [
364 'event' => $eventTicket->event->toArray(),
365 'ticketType' => $eventTicket->ticketType->toArray(),
366 ]);
367 }
368
369 return $details;
370 }
371 }
372