Issues (89)

src/Controller/TicketController.php (4 issues)

Labels
Severity
1
<?php
2
3
namespace ConferenceTools\Tickets\Controller;
4
5
use Carnage\Cqrs\MessageBus\MessageBusInterface;
6
use ConferenceTools\Tickets\Domain\Service\Availability\DiscountCodeAvailability;
7
use Doctrine\ORM\EntityManager;
8
use ConferenceTools\Tickets\Domain\Command\Ticket\AssignToDelegate;
9
use ConferenceTools\Tickets\Domain\Command\Ticket\CompletePurchase;
10
use ConferenceTools\Tickets\Domain\Command\Ticket\ReserveTickets;
11
use ConferenceTools\Tickets\Domain\Event\Ticket\TicketPurchaseCreated;
12
use ConferenceTools\Tickets\Domain\ReadModel\TicketCounts\TicketCounter;
13
use ConferenceTools\Tickets\Domain\ReadModel\TicketRecord\PurchaseRecord;
14
use ConferenceTools\Tickets\Domain\Service\Configuration;
15
use ConferenceTools\Tickets\Domain\Service\Availability\TicketAvailability;
16
use ConferenceTools\Tickets\Domain\ValueObject\Delegate;
17
use ConferenceTools\Tickets\Domain\ValueObject\TicketReservationRequest;
18
use ConferenceTools\Tickets\Form\ManageTicket;
19
use ConferenceTools\Tickets\Form\PurchaseForm;
20
use Zend\Form\FormElementManager\FormElementManagerV2Polyfill;
21
use Zend\Stdlib\ArrayObject;
22
use Zend\View\Model\ViewModel;
23
use ZfrStripe\Client\StripeClient;
24
use ZfrStripe\Exception\CardErrorException;
25
26
class TicketController extends AbstractController
27
{
28
    private static $cardErrorMessages = [
29
        'invalid_number' => 'The card number is not a valid credit card number.',
30
        'invalid_expiry_month' => 'The card\'s expiration month is invalid.',
31
        'invalid_expiry_year' => 'The card\'s expiration year is invalid.',
32
        'invalid_cvc' => 'The card\'s security code/CVC is invalid.',
33
        'invalid_swipe_data' => 'The card\'s swipe data is invalid.',
34
        'incorrect_number' => 'The card number is incorrect.',
35
        'expired_card' => 'The card has expired.',
36
        'incorrect_cvc' => 'The card\'s security code/CVC is incorrect.',
37
        'incorrect_zip' => 'The address for your card did not match the card\'s billing address.',
38
        'card_declined' => 'The card was declined.',
39
        'missing' => 'There is no card on a customer that is being charged.',
40
        'processing_error' => 'An error occurred while processing the card.',
41
    ];
42
    /**
43
     * @var TicketAvailability
44
     */
45
    private $ticketAvailability;
46
47
    /**
48
     * @var DiscountCodeAvailability
49
     */
50
    private $discountCodeAvailability;
51
52
    /**
53
     * @var FormElementManagerV2Polyfill
54
     */
55
    private $formElementManager;
56
57
    public function __construct(
58
        MessageBusInterface $commandBus,
59
        EntityManager $entityManager,
60
        StripeClient $stripeClient,
61
        Configuration $configuration,
62
        TicketAvailability $ticketAvailability,
63
        DiscountCodeAvailability $discountCodeAvailability,
64
        FormElementManagerV2Polyfill $formElementManager
65
    ) {
66
        parent::__construct($commandBus, $entityManager, $stripeClient, $configuration);
67
        $this->ticketAvailability = $ticketAvailability;
68
        $this->formElementManager = $formElementManager;
69
        $this->discountCodeAvailability = $discountCodeAvailability;
70
    }
71
72
    public function indexAction()
73
    {
74
        return $this->redirect()->toRoute('tickets/purchasing/select-tickets', [], ['force_canonical' => true]);
75
    }
76
77
    public function selectTicketsAction()
78
    {
79
        $tickets = $this->ticketAvailability->fetchAllAvailableTickets();
80
81
        if ($this->getRequest()->isPost()) {
0 ignored issues
show
The method isPost() does not exist on Zend\Stdlib\RequestInterface. It seems like you code against a sub-type of Zend\Stdlib\RequestInterface such as Zend\Http\Request. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

81
        if ($this->getRequest()->/** @scrutinizer ignore-call */ isPost()) {
Loading history...
82
            $data = $this->getRequest()->getPost();
0 ignored issues
show
The method getPost() does not exist on Zend\Stdlib\RequestInterface. It seems like you code against a sub-type of Zend\Stdlib\RequestInterface such as Zend\Http\Request. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

82
            $data = $this->getRequest()->/** @scrutinizer ignore-call */ getPost();
Loading history...
83
            $failed = false;
84
            try {
85
                $purchases = $this->validateSelectedTickets($data, $tickets);
86
            } catch (\InvalidArgumentException $e) {
87
                $failed = true;
88
            }
89
90
            try {
91
                $discountCode = $this->validateDiscountCode($data);
92
                $discountCodeStr = $data['discount_code'];
93
            } catch (\InvalidArgumentException $e) {
94
                $failed = true;
95
                $discountCodeStr = '';
96
            }
97
98
            if (!$failed) {
99
                if ($discountCode !== null) {
100
                    $command = ReserveTickets::withDiscountCode($discountCode, ...$purchases);
101
                } else {
102
                    $command = new ReserveTickets(...$purchases);
103
                }
104
                try {
105
                    $this->getCommandBus()->dispatch($command);
106
                    /** @var TicketPurchaseCreated $event */
107
                    $event = $this->events()->getEventsByType(TicketPurchaseCreated::class)[0];
108
                    return $this->redirect()->toRoute('tickets/purchasing/purchase', ['purchaseId' => $event->getId()], ['force_canonical' => true]);
109
                } catch (\DomainException $e) {
110
                    $this->flashMessenger()->addErrorMessage($e->getMessage());
111
                }
112
            }
113
        } else {
114
            try {
115
                $discountCodeStr = $this->params()->fromRoute('discount-code');
116
                $this->validateDiscountCode(['discount_code' => $discountCodeStr]);
117
            } catch (\InvalidArgumentException $e) {
118
                $discountCodeStr = '';
119
            }
120
        }
121
122
        return new ViewModel(['tickets' => $tickets, 'discountCode' => $discountCodeStr]);
123
    }
124
125
    /**
126
     * @param $data
127
     * @param TicketCounter[] $tickets
128
     * @return array
129
     */
130
    private function validateSelectedTickets($data, $tickets): array
131
    {
132
        $total = 0;
133
        $purchases = [];
134
        $errors = false;
135
        foreach ($data['quantity'] as $id => $quantity) {
136
            if (!is_numeric($quantity) || $quantity < 0) {
137
                $this->flashMessenger()->addErrorMessage('Quantity needs to be a number :)');
138
                $errors = true;
139
            } elseif (!$this->ticketAvailability->isAvailable($tickets[$id]->getTicketType(), $quantity)) {
140
                $this->flashMessenger()->addErrorMessage(
141
                    sprintf('Not enough %s remaining', $tickets[$id]->getTicketType()->getDisplayName())
142
                );
143
                $total++;
144
                $errors = true;
145
            } elseif ($quantity > 0) {
146
                $total += $quantity;
147
                $purchases[] = new TicketReservationRequest($tickets[$id]->getTicketType(), (int) $quantity);
148
            }
149
        }
150
151
        if ($errors) {
152
            throw new \InvalidArgumentException('input contained errors');
153
        }
154
155
        return $purchases;
156
    }
157
158
    /**
159
     * @param $data
160
     * @return ?DiscountCode
0 ignored issues
show
The type ConferenceTools\Tickets\Controller\DiscountCode was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
161
     */
162
    private function validateDiscountCode($data)
163
    {
164
        $discountCode = trim(strtolower($data['discount_code']));
165
        if ($discountCode === '') {
166
            return null;
167
        }
168
169
        $validCodes = $this->getConfiguration()->getDiscountCodes();
170
171
        if (!array_key_exists($discountCode, $validCodes)) {
172
            $this->flashMessenger()->addErrorMessage('Invalid discount code');
173
            throw new \InvalidArgumentException('input contained errors');
174
        }
175
176
        $discountCode = $validCodes[$discountCode];
177
178
        if (!$this->discountCodeAvailability->isAvailable($discountCode)) {
179
            $this->flashMessenger()->addErrorMessage('Discount code cannot be applied to your purchase');
180
            throw new \InvalidArgumentException('input contained errors');
181
        }
182
183
        return $discountCode;
184
    }
185
186
    public function purchaseAction()
187
    {
188
        $purchaseId = $this->params()->fromRoute('purchaseId');
189
        $noPayment = false;
190
        $purchase = $this->fetchPurchaseRecord($purchaseId);
191
192
        if ($purchase === null || $purchase->hasTimedout()) {
193
            $this->flashMessenger()->addErrorMessage('Purchase Id invalid or your purchase timed out');
194
            return $this->redirect()->toRoute('tickets/purchasing/select-tickets', [], ['force_canonical' => true]);
195
        }
196
197
        if ($purchase->isPaid()) {
198
            $this->flashMessenger()->addInfoMessage('This purchase has already been paid for');
199
            return $this->redirect()->toRoute('tickets/purchasing/complete', ['purchaseId' => $purchaseId], ['force_canonical' => true]);
200
        }
201
202
        $form = new PurchaseForm($purchase);
203
204
        if ($this->getRequest()->isPost()) {
205
            $noPayment = true;
206
            $data = $this->params()->fromPost();
207
            $form->setData($data);
208
            if ($form->isValid()) {
209
                try {
210
                    $this->getStripeClient()->createCharge([
211
                        "amount" => $purchase->getTotalCost()->getGross()->getAmount(),
212
                        "currency" => $purchase->getTotalCost()->getGross()->getCurrency(),
213
                        'source' => $data['stripe_token'],
214
                        'metadata' => [
215
                            'email' => $data['purchase_email'],
216
                            'purchaseId' => $purchaseId
217
                        ]
218
                    ]);
219
220
                    $delegateInfo = [];
221
222
                    foreach ($purchase->getTickets() as $i => $ticket) {
223
                        if (!$ticket->getTicketType()->isSupplementary()) {
224
                            $delegateInfo[] = Delegate::fromArray($data['delegates_' . $i]);
225
                        } else {
226
                            $delegateInfo[] = Delegate::emptyObject();
227
                        }
228
                    }
229
230
                    $command = new CompletePurchase($purchaseId, $data['purchase_email'], ...$delegateInfo);
231
                    $this->getCommandBus()->dispatch($command);
232
                    $this->flashMessenger()
233
                        ->addSuccessMessage(
234
                            'Your ticket purchase is completed. ' .
235
                            'You will receive an email shortly with your receipt. ' .
236
                            'Tickets will be sent to the delegates shortly before the event'
237
                        );
238
                    return $this->redirect()->toRoute('tickets/purchasing/complete', ['purchaseId' => $purchaseId], ['force_canonical' => true]);
239
                } catch (CardErrorException $e) {
240
                    $this->flashMessenger()->addErrorMessage(
241
                        sprintf(
242
                            'There was an issue with taking your payment: %s Please try again.',
243
                            $this->getDetailedErrorMessage($e)
244
                        )
245
                    );
246
                    $noPayment = false;
247
                }
248
            }
249
        }
250
251
        $this->flashMessenger()->addInfoMessage('Your tickets have been reserved for 30 mins, please complete payment before then');
252
        return new ViewModel(['purchase' => $purchase, 'form' => $form, 'noPayment' => $noPayment]);
253
    }
254
255
    /**
256
     * @param $purchaseId
257
     * @return PurchaseRecord|null
258
     */
259
    private function fetchPurchaseRecord($purchaseId)
260
    {
261
        /** @var PurchaseRecord $purchase */
262
        $purchase = $this->getEntityManager()->getRepository(PurchaseRecord::class)->findOneBy([
263
            'purchaseId' => $purchaseId
264
        ]);
265
        return $purchase;
266
    }
267
268
    private function getDetailedErrorMessage(CardErrorException $e)
269
    {
270
        $response = $e->getResponse();
271
        $errors = json_decode($response->getBody(true), true);
272
        $code = isset($errors['error']['code']) ? $errors['error']['code'] : 'processing_error';
273
        $code = isset(static::$cardErrorMessages[$code]) ? $code : 'processing_error';
0 ignored issues
show
Since $cardErrorMessages is declared private, accessing it with static will lead to errors in possible sub-classes; you can either use self, or increase the visibility of $cardErrorMessages to at least protected.
Loading history...
274
275
        return static::$cardErrorMessages[$code];
276
    }
277
278
    public function completeAction()
279
    {
280
        $purchaseId = $this->params()->fromRoute('purchaseId');
281
        $purchase = $this->fetchPurchaseRecord($purchaseId);
282
283
        if ($purchase === null) {
284
            $this->flashMessenger()->addErrorMessage('Purchase Id invalid');
285
            return $this->redirect()->toRoute('tickets/purchasing/select-tickets', [], ['force_canonical' => true]);
286
        }
287
288
        return new ViewModel(['purchase' => $purchase]);
289
    }
290
291
    public function manageAction()
292
    {
293
        $purchaseId = $this->params()->fromRoute('purchaseId');
294
        $ticketId = $this->params()->fromRoute('ticketId');
295
296
        $purchase = $this->fetchPurchaseRecord($purchaseId);
297
        $ticketRecord = $purchase->getTicketRecord($ticketId);
298
        $delegate = $ticketRecord->getDelegate();
299
300
        $form = $this->formElementManager->get(ManageTicket::class);
301
        $data = [
302
            'delegate' => $delegate->toArray()
303
        ];
304
305
        $form->bind(new ArrayObject($data));
306
307
        if ($this->getRequest()->isPost()) {
308
            $form->setData($this->params()->fromPost());
309
            if ($form->isValid()) {
310
                $data = $form->getData();
311
                $newDelegateInfo = Delegate::fromArray($data['delegate']);
312
313
                $command = new AssignToDelegate($newDelegateInfo, $ticketId, $purchaseId);
314
                $this->getCommandBus()->dispatch($command);
315
                $this->flashMessenger()
316
                    ->addSuccessMessage(
317
                        'Details updated successfully'
318
                    );
319
                return $this->redirect()->refresh();
320
            }
321
        }
322
323
        return new ViewModel(['purchase' => $purchase, 'form' => $form]);
324
    }
325
}
326