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