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