]> git.agnieray.net Git - galette.git/blob - galette/lib/Galette/Entity/Transaction.php
Fix transaction creation form
[galette.git] / galette / lib / Galette / Entity / Transaction.php
1 <?php
2
3 /**
4 * Copyright © 2003-2024 The Galette Team
5 *
6 * This file is part of Galette (https://galette.eu).
7 *
8 * Galette is free software: you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation, either version 3 of the License, or
11 * (at your option) any later version.
12 *
13 * Galette is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
17 *
18 * You should have received a copy of the GNU General Public License
19 * along with Galette. If not, see <http://www.gnu.org/licenses/>.
20 */
21
22 namespace Galette\Entity;
23
24 use ArrayObject;
25 use Galette\Events\GaletteEvent;
26 use Galette\Repository\PaymentTypes;
27 use Throwable;
28 use Analog\Analog;
29 use Laminas\Db\Sql\Expression;
30 use Galette\Repository\Contributions;
31 use Galette\Core\Db;
32 use Galette\Core\History;
33 use Galette\Core\Login;
34 use Galette\Features\Dynamics;
35
36 /**
37 * Transaction class for galette
38 *
39 * @author Johan Cwiklinski <johan@x-tnd.be>
40 *
41 * @property integer $id
42 * @property date $date
43 * @property integer $amount
44 * @property ?string $description
45 * @property ?integer $member
46 * @property ?integer $payment_type
47 */
48 class Transaction
49 {
50 use Dynamics;
51
52 public const TABLE = 'transactions';
53 public const PK = 'trans_id';
54
55 private int $_id;
56 private string $_date;
57 private float $_amount;
58 private ?string $_description = null;
59 private ?int $_member = null;
60 private ?int $_payment_type = null;
61
62 /**
63 * fields list and their translation
64 *
65 * @var array<string, array<string, string>>
66 */
67 private array $_fields;
68
69 private Db $zdb;
70 private Login $login;
71
72 /** @var array<string> */
73 private array $errors;
74
75 /**
76 * Default constructor
77 *
78 * @param Db $zdb Database instance
79 * @param Login $login Login instance
80 * @param null|int|ArrayObject<string, int|string> $args Either a ResultSet row or its id for to load
81 * a specific transaction, or null to just
82 * instantiate object
83 */
84 public function __construct(Db $zdb, Login $login, ArrayObject|int|null $args = null)
85 {
86 $this->zdb = $zdb;
87 $this->login = $login;
88
89 /*
90 * Fields configuration. Each field is an array and must reflect:
91 * array(
92 * (string)label,
93 * (string) propname
94 * )
95 *
96 * I'd prefer a static private variable for this...
97 * But call to the _T function does not seem to be allowed there :/
98 */
99 $this->_fields = array(
100 self::PK => array(
101 'label' => null, //not a field in the form
102 'propname' => 'id'
103 ),
104 'trans_date' => array(
105 'label' => _T("Date:"), //not a field in the form
106 'propname' => 'date'
107 ),
108 'trans_amount' => array(
109 'label' => _T("Amount:"),
110 'propname' => 'amount'
111 ),
112 'trans_desc' => array(
113 'label' => _T("Description:"),
114 'propname' => 'description'
115 ),
116 Adherent::PK => array(
117 'label' => _T("Originator:"),
118 'propname' => 'member'
119 ),
120 'type_paiement_trans' => array(
121 'label' => _T("Payment type:"),
122 'propname' => 'payment_type'
123 )
124 );
125 if ($args === null || is_int($args)) {
126 $this->_date = date("Y-m-d");
127
128 if (is_int($args) && $args > 0) {
129 $this->load($args);
130 }
131 } elseif ($args instanceof ArrayObject) {
132 $this->loadFromRS($args);
133 }
134
135 $this->loadDynamicFields();
136 }
137
138 /**
139 * Loads a transaction from its id
140 *
141 * @param int $id the identifier for the transaction to load
142 *
143 * @return bool true if query succeed, false otherwise
144 */
145 public function load(int $id): bool
146 {
147 if (!$this->login->isLogged()) {
148 Analog::log(
149 'Non-logged-in users cannot load transaction id `' . $id,
150 Analog::ERROR
151 );
152 return false;
153 }
154
155 try {
156 $select = $this->zdb->select(self::TABLE, 't');
157 $select->where([self::PK => $id]);
158 $select->join(
159 array('a' => PREFIX_DB . Adherent::TABLE),
160 't.' . Adherent::PK . '=a.' . Adherent::PK,
161 array()
162 );
163
164 //restrict query on current member id if he's not admin nor staff member
165 if (!$this->login->isAdmin() && !$this->login->isStaff()) {
166 $select->where
167 ->nest()
168 ->equalTo('a.' . Adherent::PK, $this->login->id)
169 ->or
170 ->equalTo('a.parent_id', $this->login->id)
171 ->unnest()
172 ->and
173 ->equalTo('t.' . self::PK, $id)
174 ;
175 } else {
176 $select->where->equalTo(self::PK, $id);
177 }
178
179 $results = $this->zdb->execute($select);
180 if ($results->count() > 0) {
181 /** @var ArrayObject<string, int|string> $result */
182 $result = $results->current();
183 $this->loadFromRS($result);
184 return true;
185 } else {
186 Analog::log(
187 'Transaction id `' . $id . '` does not exists',
188 Analog::WARNING
189 );
190 return false;
191 }
192 } catch (Throwable $e) {
193 Analog::log(
194 'Cannot load transaction form id `' . $id . '` | ' .
195 $e->getMessage(),
196 Analog::WARNING
197 );
198 throw $e;
199 }
200 }
201
202 /**
203 * Remove transaction (and all associated contributions) from database
204 *
205 * @param History $hist History
206 * @param boolean $transaction Activate transaction mode (defaults to true)
207 *
208 * @return boolean
209 */
210 public function remove(History $hist, bool $transaction = true): bool
211 {
212 global $emitter;
213
214 try {
215 if ($transaction) {
216 $this->zdb->connection->beginTransaction();
217 }
218
219 //remove associated contributions if needeed
220 if ($this->getDispatchedAmount() > 0) {
221 $c = new Contributions($this->zdb, $this->login);
222 $clist = $c->getListFromTransaction($this->_id);
223 $cids = array();
224 foreach ($clist as $cid) {
225 $cids[] = $cid->id;
226 }
227 $c->remove($cids, $hist, false);
228 }
229
230 //remove transaction itself
231 $delete = $this->zdb->delete(self::TABLE);
232 $delete->where([self::PK => $this->_id]);
233 $del = $this->zdb->execute($delete);
234 if ($del->count() > 0) {
235 $this->dynamicsRemove(true);
236 } else {
237 Analog::log(
238 'Transaction has not been removed!',
239 Analog::WARNING
240 );
241 return false;
242 }
243
244 if ($transaction) {
245 $this->zdb->connection->commit();
246 }
247
248 $emitter->dispatch(new GaletteEvent('transaction.remove', $this));
249 return true;
250 } catch (Throwable $e) {
251 if ($transaction) {
252 $this->zdb->connection->rollBack();
253 }
254 Analog::log(
255 'An error occurred trying to remove transaction #' .
256 $this->_id . ' | ' . $e->getMessage(),
257 Analog::ERROR
258 );
259 throw $e;
260 }
261 }
262
263 /**
264 * Populate object from a resultset row
265 *
266 * @param ArrayObject<string,int|string> $r the resultset row
267 *
268 * @return void
269 */
270 private function loadFromRS(ArrayObject $r): void
271 {
272 $pk = self::PK;
273 $this->_id = $r->$pk;
274 $this->_date = $r->trans_date;
275 $this->_amount = $r->trans_amount;
276 $this->_description = $r->trans_desc;
277 $adhpk = Adherent::PK;
278 $this->_member = (int)$r->$adhpk;
279 $this->_payment_type = $r->type_paiement_trans;
280
281 $this->loadDynamicFields();
282 }
283
284 /**
285 * Check posted values validity
286 *
287 * @param array<string,mixed> $values All values to check, basically the $_POST array
288 * after sending the form
289 * @param array<string,int> $required Array of required fields
290 * @param array<string> $disabled Array of disabled fields
291 *
292 * @return true|array<string>
293 */
294 public function check(array $values, array $required, array $disabled): bool|array
295 {
296 global $preferences;
297 $this->errors = array();
298
299 $fields = array_keys($this->_fields);
300 foreach ($fields as $key) {
301 //first, let's sanitize values
302 $key = strtolower($key);
303 $prop = '_' . $this->_fields[$key]['propname'];
304
305 if (isset($values[$key])) {
306 $value = trim($values[$key]);
307 } else {
308 $value = '';
309 }
310
311 // if the field is enabled, check it
312 if (!isset($disabled[$key])) {
313 // now, check validity
314 if ($value != '') {
315 switch ($key) {
316 // dates
317 case 'trans_date':
318 try {
319 $d = \DateTime::createFromFormat(__("Y-m-d"), $value);
320 if ($d === false) {
321 //try with non localized date
322 $d = \DateTime::createFromFormat("Y-m-d", $value);
323 if ($d === false) {
324 throw new \Exception('Incorrect format');
325 }
326 }
327 $this->$prop = $d->format('Y-m-d');
328 } catch (Throwable $e) {
329 Analog::log(
330 'Wrong date format. field: ' . $key .
331 ', value: ' . $value . ', expected fmt: ' .
332 __("Y-m-d") . ' | ' . $e->getMessage(),
333 Analog::INFO
334 );
335 $this->errors[] = sprintf(
336 //TRANS: %1$s is the date format, %2$s is the field name
337 _T('- Wrong date format (%1$s) for %2$s!'),
338 __("Y-m-d"),
339 $this->getFieldLabel($key)
340 );
341 }
342 break;
343 case Adherent::PK:
344 $this->_member = (int)$value;
345 break;
346 case 'trans_amount':
347 $value = strtr($value, ',', '.');
348 $this->_amount = (double)$value;
349 if (!is_numeric($value)) {
350 $this->errors[] = _T("- The amount must be an integer!");
351 }
352 break;
353 case 'trans_desc':
354 /** TODO: retrieve field length from database and check that */
355 $this->_description = strip_tags($value);
356 if (mb_strlen($value) > 150) {
357 $this->errors[] = _T("- Transaction description must be 150 characters long maximum.");
358 }
359 break;
360 case 'type_paiement_trans':
361 if ($value == 0) {
362 break;
363 }
364 $ptypes = new PaymentTypes(
365 $this->zdb,
366 $preferences,
367 $this->login
368 );
369 $ptlist = $ptypes->getList();
370 if (isset($ptlist[$value])) {
371 $this->_payment_type = (int)$value;
372 } else {
373 $this->errors[] = _T("- Unknown payment type");
374 }
375 break;
376 }
377 }
378 }
379 }
380
381 // missing required fields?
382 foreach ($required as $key => $val) {
383 if ($val === 1) {
384 $prop = '_' . $this->_fields[$key]['propname'];
385 if (!isset($disabled[$key]) && !isset($this->$prop)) {
386 $this->errors[] = str_replace(
387 '%field',
388 '<a href="#' . $key . '">' . $this->getFieldLabel($key) . '</a>',
389 _T("- Mandatory field %field empty.")
390 );
391 }
392 }
393 }
394
395 if (isset($this->_id)) {
396 $dispatched = $this->getDispatchedAmount();
397 if ($dispatched > $this->_amount) {
398 $this->errors[] = _T("- Sum of all contributions exceed corresponding transaction amount.");
399 }
400 }
401
402 $this->dynamicsCheck($values, $required, $disabled);
403
404 if (count($this->errors) > 0) {
405 Analog::log(
406 'Some errors has been thew attempting to edit/store a transaction' .
407 print_r($this->errors, true),
408 Analog::DEBUG
409 );
410 return $this->errors;
411 } else {
412 Analog::log(
413 'Transaction checked successfully.',
414 Analog::DEBUG
415 );
416 return true;
417 }
418 }
419
420 /**
421 * Store the transaction
422 *
423 * @param History $hist History
424 *
425 * @return boolean
426 */
427 public function store(History $hist): bool
428 {
429 global $emitter;
430
431 $event = null;
432
433 try {
434 $this->zdb->connection->beginTransaction();
435 $values = array();
436 $fields = $this->getDbFields($this->zdb);
437 /** FIXME: quote? */
438 foreach ($fields as $field) {
439 $prop = '_' . $this->_fields[$field]['propname'];
440 if (isset($this->$prop)) {
441 $values[$field] = $this->$prop;
442 }
443 }
444
445 if (!isset($this->_id) || $this->_id == '') {
446 //we're inserting a new transaction
447 unset($values[self::PK]);
448 $insert = $this->zdb->insert(self::TABLE);
449 $insert->values($values);
450 $add = $this->zdb->execute($insert);
451 if ($add->count() > 0) {
452 $this->_id = $this->zdb->getLastGeneratedValue($this);
453
454 // logging
455 $hist->add(
456 _T("Transaction added"),
457 Adherent::getSName($this->zdb, $this->_member)
458 );
459 $event = 'transaction.add';
460 } else {
461 $hist->add(_T("Fail to add new transaction."));
462 throw new \RuntimeException(
463 'An error occurred inserting new transaction!'
464 );
465 }
466 } else {
467 //we're editing an existing transaction
468 $update = $this->zdb->update(self::TABLE);
469 $update->set($values)->where([self::PK => $this->_id]);
470 $edit = $this->zdb->execute($update);
471 //edit == 0 does not mean there were an error, but that there
472 //were nothing to change
473 if ($edit->count() > 0) {
474 $hist->add(
475 _T("Transaction updated"),
476 Adherent::getSName($this->zdb, $this->_member)
477 );
478 }
479 $event = 'transaction.edit';
480 }
481
482 //dynamic fields
483 $this->dynamicsStore(true);
484
485 $this->zdb->connection->commit();
486
487 //send event at the end of process, once all has been stored
488 if ($event !== null) {
489 $emitter->dispatch(new GaletteEvent($event, $this));
490 }
491
492 return true;
493 } catch (Throwable $e) {
494 $this->zdb->connection->rollBack();
495 Analog::log(
496 'Something went wrong :\'( | ' . $e->getMessage() . "\n" .
497 $e->getTraceAsString(),
498 Analog::ERROR
499 );
500 throw $e;
501 }
502 }
503
504 /**
505 * Retrieve amount that has already been dispatched into contributions
506 *
507 * @return double
508 */
509 public function getDispatchedAmount(): float
510 {
511 if (empty($this->_id)) {
512 return (double)0;
513 }
514
515 try {
516 $select = $this->zdb->select(Contribution::TABLE);
517 $select->columns(
518 array(
519 'sum' => new Expression('SUM(montant_cotis)')
520 )
521 )->where([self::PK => $this->_id]);
522
523 $results = $this->zdb->execute($select);
524 $result = $results->current();
525 $dispatched_amount = $result->sum;
526 return (double)$dispatched_amount;
527 } catch (Throwable $e) {
528 Analog::log(
529 'An error occurred retrieving dispatched amounts | ' .
530 $e->getMessage(),
531 Analog::ERROR
532 );
533 throw $e;
534 }
535 }
536
537 /**
538 * Retrieve amount that has not yet been dispatched into contributions
539 *
540 * @return double
541 */
542 public function getMissingAmount(): float
543 {
544 if (empty($this->_id)) {
545 return (double)$this->amount;
546 }
547
548 try {
549 $select = $this->zdb->select(Contribution::TABLE);
550 $select->columns(
551 array(
552 'sum' => new Expression('SUM(montant_cotis)')
553 )
554 )->where([self::PK => $this->_id]);
555
556 $results = $this->zdb->execute($select);
557 $result = $results->current();
558 $dispatched_amount = $result->sum;
559 return (double)$this->_amount - (double)$dispatched_amount;
560 } catch (Throwable $e) {
561 Analog::log(
562 'An error occurred retrieving missing amounts | ' .
563 $e->getMessage(),
564 Analog::ERROR
565 );
566 throw $e;
567 }
568 }
569
570 /**
571 * Get payment type label
572 *
573 * @return string
574 */
575 public function getPaymentType(): string
576 {
577 if ($this->_payment_type === null) {
578 return '-';
579 }
580
581 $ptype = new PaymentType($this->zdb, $this->payment_type);
582 return $ptype->getName();
583 }
584
585 /**
586 * Retrieve fields from database
587 *
588 * @param Db $zdb Database instance
589 *
590 * @return array<string>
591 */
592 public function getDbFields(Db $zdb): array
593 {
594 $columns = $zdb->getColumns(self::TABLE);
595 $fields = array();
596 foreach ($columns as $col) {
597 $fields[] = $col->getName();
598 }
599 return $fields;
600 }
601
602 /**
603 * Get the relevant CSS class for current transaction
604 *
605 * @return string current transaction row class
606 */
607 public function getRowClass(): string
608 {
609 return ($this->getMissingAmount() == 0) ?
610 'transaction-normal' : 'transaction-uncomplete';
611 }
612
613 /**
614 * Global getter method
615 *
616 * @param string $name name of the property we want to retrieve
617 *
618 * @return mixed the called property
619 */
620 public function __get(string $name)
621 {
622 $forbidden = array();
623
624 $rname = '_' . $name;
625 if (!in_array($name, $forbidden) && property_exists($this, $rname)) {
626 switch ($name) {
627 case 'date':
628 if ($this->$rname != '') {
629 try {
630 $d = new \DateTime($this->$rname);
631 return $d->format(__("Y-m-d"));
632 } catch (Throwable $e) {
633 //oops, we've got a bad date :/
634 Analog::log(
635 'Bad date (' . $this->$rname . ') | ' .
636 $e->getMessage(),
637 Analog::INFO
638 );
639 return $this->$rname;
640 }
641 }
642 break;
643 case 'id':
644 if (isset($this->$rname) && $this->$rname !== null) {
645 return (int)$this->$rname;
646 }
647 return null;
648 case 'amount':
649 if (isset($this->$rname)) {
650 return (double)$this->$rname;
651 }
652 return null;
653 default:
654 return $this->$rname;
655 }
656 } else {
657 Analog::log(
658 sprintf(
659 'Property %1$s does not exists for transaction',
660 $name
661 ),
662 Analog::WARNING
663 );
664 return false;
665 }
666 }
667
668 /**
669 * Global isset method
670 * Required for twig to access properties via __get
671 *
672 * @param string $name name of the property we want to retrieve
673 *
674 * @return bool
675 */
676 public function __isset(string $name): bool
677 {
678 $forbidden = array();
679
680 $rname = '_' . $name;
681 if (!in_array($name, $forbidden) && property_exists($this, $rname)) {
682 return true;
683 }
684
685 return false;
686 }
687
688 /**
689 * Get field label
690 *
691 * @param string $field Field name
692 *
693 * @return string
694 */
695 public function getFieldLabel(string $field): string
696 {
697 $label = $this->_fields[$field]['label'];
698 //replace "&nbsp;"
699 $label = str_replace('&nbsp;', ' ', $label);
700 //remove trailing ':' and then trim
701 $label = trim(trim($label, ':'));
702 return $label;
703 }
704
705 /**
706 * Handle files (dynamics files)
707 *
708 * @param array<string,mixed> $files Files sent
709 *
710 * @return array<string>|true
711 */
712 public function handleFiles(array $files)
713 {
714 $this->errors = [];
715
716 $this->dynamicsFiles($files);
717
718 if (count($this->errors) > 0) {
719 Analog::log(
720 'Some errors has been thew attempting to edit/store a transaction files' . "\n" .
721 print_r($this->errors, true),
722 Analog::ERROR
723 );
724 return $this->errors;
725 } else {
726 return true;
727 }
728 }
729
730 /**
731 * Can current logged-in user display transaction
732 *
733 * @param Login $login Login instance
734 *
735 * @return boolean
736 */
737 public function canShow(Login $login): bool
738 {
739 //non-logged-in members cannot show contributions
740 if (!$login->isLogged()) {
741 return false;
742 }
743
744 //admin and staff users can edit, as well as member itself
745 if (!$this->id || $login->id == $this->_member || $login->isAdmin() || $login->isStaff()) {
746 return true;
747 }
748
749 //parent can see their children transactions
750 $parent = new Adherent($this->zdb);
751 $parent
752 ->disableAllDeps()
753 ->enableDep('children')
754 ->load($this->login->id);
755 if ($parent->hasChildren()) {
756 foreach ($parent->children as $child) {
757 if ($child->id === $this->_member) {
758 return true;
759 }
760 }
761 return false;
762 }
763
764 return false;
765 }
766 }