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