]> git.agnieray.net Git - galette.git/blob - galette/lib/Galette/Entity/Contribution.php
Disable events from mass changes; closes #1733
[galette.git] / galette / lib / Galette / Entity / Contribution.php
1 <?php
2
3 /* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
4
5 /**
6 * Contribution class for galette
7 * Manage membership fees and donations.
8 *
9 * PHP version 5
10 *
11 * Copyright © 2010-2023 The Galette Team
12 *
13 * This file is part of Galette (http://galette.tuxfamily.org).
14 *
15 * Galette is free software: you can redistribute it and/or modify
16 * it under the terms of the GNU General Public License as published by
17 * the Free Software Foundation, either version 3 of the License, or
18 * (at your option) any later version.
19 *
20 * Galette is distributed in the hope that it will be useful,
21 * but WITHOUT ANY WARRANTY; without even the implied warranty of
22 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23 * GNU General Public License for more details.
24 *
25 * You should have received a copy of the GNU General Public License
26 * along with Galette. If not, see <http://www.gnu.org/licenses/>.
27 *
28 * @category Entity
29 * @package Galette
30 *
31 * @author Johan Cwiklinski <johan@x-tnd.be>
32 * @copyright 2010-2023 The Galette Team
33 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL License 3.0 or (at your option) any later version
34 * @link http://galette.tuxfamily.org
35 * @since Available since 0.7dev - 2010-03-11
36 */
37
38 namespace Galette\Entity;
39
40 use ArrayObject;
41 use DateTime;
42 use Galette\Events\GaletteEvent;
43 use Galette\Features\HasEvent;
44 use Throwable;
45 use Analog\Analog;
46 use Laminas\Db\Sql\Expression;
47 use Galette\Core\Db;
48 use Galette\Core\Login;
49 use Galette\IO\ExternalScript;
50 use Galette\IO\PdfContribution;
51 use Galette\Repository\PaymentTypes;
52 use Galette\Features\Dynamics;
53
54 /**
55 * Contribution class for galette
56 * Manage membership fees and donations.
57 *
58 * @category Entity
59 * @name Contribution
60 * @package Galette
61 * @author Johan Cwiklinski <johan@x-tnd.be>
62 * @copyright 2010-2023 The Galette Team
63 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL License 3.0 or (at your option) any later version
64 * @link http://galette.tuxfamily.org
65 * @since Available since 0.7dev - 2010-03-11
66 *
67 * @property integer $id
68 * @property string $date
69 * @property DateTime $raw_date
70 * @property integer $member
71 * @property ContributionsTypes|int $type
72 * @property double $amount
73 * @property integer $payment_type
74 * @property double $orig_amount
75 * @property string $info
76 * @property string $begin_date
77 * @property DateTime $raw_begin_date
78 * @property string $end_date
79 * @property DateTime $raw_end_date
80 * @property Transaction|null $transaction
81 * @property integer $extension
82 * @property integer $duration
83 * @property string $spayment_type
84 * @property integer $model
85 */
86 class Contribution
87 {
88 use Dynamics;
89 use HasEvent;
90
91 public const TABLE = 'cotisations';
92 public const PK = 'id_cotis';
93
94 public const TYPE_FEE = 'fee';
95 public const TYPE_DONATION = 'donation';
96
97 private $_id;
98 private $_date;
99 private $_member;
100 private $_type;
101 private $_amount;
102 private $_payment_type;
103 private $_orig_amount;
104 private $_info;
105 private $_begin_date;
106 private $_end_date;
107 private $_transaction = null;
108 private $_is_cotis;
109 private $_extension;
110
111 //fields list and their translation
112 private $_fields;
113
114 /** @var Db */
115 private $zdb;
116 /** @var Login */
117 private $login;
118 /** @var array */
119 private $errors;
120
121 private $sendmail = false;
122
123 /**
124 * Default constructor
125 *
126 * @param Db $zdb Database
127 * @param Login $login Login instance
128 * @param null|int|array|ArrayObject $args Either a ResultSet row to load
129 * a specific contribution, or a type id
130 * to just instantiate object
131 */
132 public function __construct(Db $zdb, Login $login, $args = null)
133 {
134 $this->zdb = $zdb;
135 $this->login = $login;
136
137 global $preferences;
138 $this->_payment_type = (int)$preferences->pref_default_paymenttype;
139
140 $this
141 ->withAddEvent()
142 ->withEditEvent()
143 ->withoutDeleteEvent()
144 ->activateEvents();
145
146 /*
147 * Fields configuration. Each field is an array and must reflect:
148 * array(
149 * (string)label,
150 * (string) property name
151 * )
152 *
153 * I'd prefer a static private variable for this...
154 * But call to the _T function does not seem to be allowed there :/
155 */
156 $this->_fields = array(
157 'id_cotis' => array(
158 'label' => _T('Contribution id'), //not a field in the form
159 'propname' => 'id'
160 ),
161 Adherent::PK => array(
162 'label' => _T("Contributor:"),
163 'propname' => 'member'
164 ),
165 ContributionsTypes::PK => array(
166 'label' => _T("Contribution type:"),
167 'propname' => 'type'
168 ),
169 'montant_cotis' => array(
170 'label' => _T("Amount:"),
171 'propname' => 'amount'
172 ),
173 'type_paiement_cotis' => array(
174 'label' => _T("Payment type:"),
175 'propname' => 'payment_type'
176 ),
177 'info_cotis' => array(
178 'label' => _T("Comments:"),
179 'propname' => 'info'
180 ),
181 'date_enreg' => array(
182 'label' => _T('Date'), //not a field in the form
183 'propname' => 'date'
184 ),
185 'date_debut_cotis' => array(
186 'label' => _T("Date of contribution:"),
187 'cotlabel' => _T("Start date of membership:"), //if contribution is a membership fee, label differs
188 'propname' => 'begin_date'
189 ),
190 'date_fin_cotis' => array(
191 'label' => _T("End date of membership:"),
192 'propname' => 'end_date'
193 ),
194 Transaction::PK => array(
195 'label' => _T('Transaction ID'), //not a field in the form
196 'propname' => 'transaction'
197 ),
198 //this one is not really a field, but is required in some cases...
199 //adding it here make more simple to check required fields
200 'duree_mois_cotis' => array(
201 'label' => _T("Membership extension:"),
202 'propname' => 'extension'
203 )
204 );
205 if (is_int($args)) {
206 $this->load($args);
207 } elseif (is_array($args)) {
208 $this->_date = date("Y-m-d");
209 if (isset($args['adh']) && $args['adh'] != '') {
210 $this->_member = (int)$args['adh'];
211 }
212 if (isset($args['trans'])) {
213 $this->_transaction = new Transaction($this->zdb, $this->login, (int)$args['trans']);
214 if (!isset($this->_member)) {
215 $this->_member = (int)$this->_transaction->member;
216 }
217 $this->_amount = $this->_transaction->getMissingAmount();
218 }
219 $this->type = (int)$args['type'];
220 //calculate begin date for membership fee
221 $this->_begin_date = $this->_date;
222 if ($this->_is_cotis) {
223 $due_date = self::getDueDate($this->zdb, $this->_member);
224 if ($due_date != '') {
225 $now = new \DateTime();
226 $due_date = new \DateTime($due_date);
227 if ($due_date < $now) {
228 // Member didn't renew on time
229 $this->_begin_date = $now->format('Y-m-d');
230 } else {
231 // Caution : the next_begin_date is the day after the due_date.
232 $next_begin_date = clone $due_date;
233 $next_begin_date->add(new \DateInterval('P1D'));
234 $this->_begin_date = $next_begin_date->format('Y-m-d');
235 }
236 }
237 $this->retrieveEndDate();
238 }
239 if (isset($args['payment_type'])) {
240 $this->_payment_type = $args['payment_type'];
241 }
242 } elseif (is_object($args)) {
243 $this->loadFromRS($args);
244 }
245
246 $this->loadDynamicFields();
247 }
248
249 /**
250 * Sets end contribution date
251 *
252 * @return void
253 */
254 private function retrieveEndDate()
255 {
256 global $preferences;
257
258 $now = new \DateTime();
259 $begin_date = new \DateTime($this->_begin_date);
260 if ($preferences->pref_beg_membership != '') {
261 //case beginning of membership
262 list($j, $m) = explode('/', $preferences->pref_beg_membership);
263 $next_begin_date = new \DateTime($begin_date->format('Y') . '-' . $m . '-' . $j);
264 while ($next_begin_date <= $begin_date) {
265 $next_begin_date->add(new \DateInterval('P1Y'));
266 }
267
268 if ($preferences->pref_membership_offermonths > 0) {
269 //count days until next membership begin date
270 $diff1 = (int)$now->diff($next_begin_date)->format('%a');
271
272 //count days between next membership begin date and offered months
273 $tdate = clone $next_begin_date;
274 $tdate->sub(new \DateInterval('P' . $preferences->pref_membership_offermonths . 'M'));
275 $diff2 = (int)$next_begin_date->diff($tdate)->format('%a');
276
277 //when number of days until next membership begin date is less than or equal to the offered months, it's free :)
278 if ($diff1 <= $diff2) {
279 $next_begin_date->add(new \DateInterval('P1Y'));
280 }
281 }
282
283 // Caution : the end_date to retrieve is the day before the next_begin_date.
284 $end_date = clone $next_begin_date;
285 $end_date->sub(new \DateInterval('P1D'));
286 $this->_end_date = $end_date->format('Y-m-d');
287 } elseif ($preferences->pref_membership_ext != '') {
288 //case membership extension
289 if ($this->_extension == null) {
290 $this->_extension = $preferences->pref_membership_ext;
291 }
292 $dext = new \DateInterval('P' . $this->_extension . 'M');
293 // Caution : the end_date to retrieve is the day before the next_begin_date.
294 $next_begin_date = $begin_date->add($dext);
295 $end_date = clone $next_begin_date;
296 $end_date->sub(new \DateInterval('P1D'));
297 $this->_end_date = $end_date->format('Y-m-d');
298 } else {
299 throw new \RuntimeException(
300 'Unable to define end date; none of pref_beg_membership nor pref_membership_ext are defined!'
301 );
302 }
303 }
304
305 /**
306 * Loads a contribution from its id
307 *
308 * @param int $id the identifier for the contribution to load
309 *
310 * @return bool true if query succeed, false otherwise
311 */
312 public function load($id)
313 {
314 if (!$this->login->isLogged() && $this->login->id == '') {
315 return false;
316 }
317
318 try {
319 $select = $this->zdb->select(self::TABLE, 'c');
320 $select->join(
321 array('a' => PREFIX_DB . Adherent::TABLE),
322 'c.' . Adherent::PK . '=a.' . Adherent::PK,
323 array()
324 );
325 //restrict query on current member id if he's not admin nor staff member
326 if (!$this->login->isAdmin() && !$this->login->isStaff()) {
327 $select->where
328 ->nest()
329 ->equalTo('a.' . Adherent::PK, $this->login->id)
330 ->or
331 ->equalTo('a.parent_id', $this->login->id)
332 ->unnest()
333 ->and
334 ->equalTo('c.' . self::PK, $id)
335 ;
336 } else {
337 $select->where->equalTo(self::PK, $id);
338 }
339
340 $results = $this->zdb->execute($select);
341 if ($results->count() > 0) {
342 $row = $results->current();
343 $this->loadFromRS($row);
344 return true;
345 } else {
346 Analog::log(
347 'No contribution #' . $id . ' (user ' . $this->login->id . ')',
348 Analog::ERROR
349 );
350 return false;
351 }
352 } catch (Throwable $e) {
353 Analog::log(
354 'An error occurred attempting to load contribution #' . $id .
355 $e->getMessage(),
356 Analog::ERROR
357 );
358 throw $e;
359 }
360 }
361
362 /**
363 * Populate object from a resultset row
364 *
365 * @param ArrayObject $r the resultset row
366 *
367 * @return void
368 */
369 private function loadFromRS(ArrayObject $r)
370 {
371 $pk = self::PK;
372 $this->_id = (int)$r->$pk;
373 $this->_date = $r->date_enreg;
374 $this->_amount = (double)$r->montant_cotis;
375 //save original amount, we need it for transactions parts calculations
376 $this->_orig_amount = (double)$r->montant_cotis;
377 $this->_payment_type = $r->type_paiement_cotis;
378 $this->_info = $r->info_cotis;
379 $this->_begin_date = $r->date_debut_cotis;
380 $end_date = $r->date_fin_cotis;
381 //do not work with knows bad dates...
382 //the one with BC comes from 0.63/pgsql demo... Why the hell a so
383 //strange date? don't know :(
384 if (
385 $end_date !== '0000-00-00'
386 && $end_date !== '1901-01-01'
387 && $end_date !== '0001-01-01 BC'
388 ) {
389 $this->_end_date = $r->date_fin_cotis;
390 }
391 $adhpk = Adherent::PK;
392 $this->_member = (int)$r->$adhpk;
393
394 $transpk = Transaction::PK;
395 if ($r->$transpk != '') {
396 $this->_transaction = new Transaction($this->zdb, $this->login, (int)$r->$transpk);
397 }
398
399 $this->type = (int)$r->id_type_cotis;
400 $this->loadDynamicFields();
401 }
402
403 /**
404 * Check posted values validity
405 *
406 * @param array $values All values to check, basically the $_POST array
407 * after sending the form
408 * @param array $required Array of required fields
409 * @param array $disabled Array of disabled fields
410 *
411 * @return true|array
412 */
413 public function check($values, $required, $disabled)
414 {
415 global $preferences;
416 $this->errors = array();
417
418 $fields = array_keys($this->_fields);
419 foreach ($fields as $key) {
420 //first, let's sanitize values
421 $key = strtolower($key);
422 $prop = '_' . $this->_fields[$key]['propname'];
423
424 if (isset($values[$key])) {
425 $value = trim($values[$key]);
426 } else {
427 $value = '';
428 }
429
430 // if the field is enabled, check it
431 if (!isset($disabled[$key])) {
432 // fill up the adherent structure
433 //$this->$prop = stripslashes($value); //not relevant here!
434
435 // now, check validity
436 switch ($key) {
437 // dates
438 case 'date_enreg':
439 case 'date_debut_cotis':
440 case 'date_fin_cotis':
441 if ($value != '') {
442 try {
443 $d = \DateTime::createFromFormat(__("Y-m-d"), $value);
444 if ($d === false) {
445 //try with non localized date
446 $d = \DateTime::createFromFormat("Y-m-d", $value);
447 if ($d === false) {
448 throw new \Exception('Incorrect format');
449 }
450 }
451 $this->$prop = $d->format('Y-m-d');
452 } catch (Throwable $e) {
453 Analog::log(
454 'Wrong date format. field: ' . $key .
455 ', value: ' . $value . ', expected fmt: ' .
456 __("Y-m-d") . ' | ' . $e->getMessage(),
457 Analog::INFO
458 );
459 $this->errors[] = str_replace(
460 array(
461 '%date_format',
462 '%field'
463 ),
464 array(
465 __("Y-m-d"),
466 $this->_fields[$key]['label']
467 ),
468 _T("- Wrong date format (%date_format) for %field!")
469 );
470 }
471 }
472 break;
473 case Adherent::PK:
474 if ($value != '') {
475 $this->_member = (int)$value;
476 }
477 break;
478 case ContributionsTypes::PK:
479 if ($value != '') {
480 $this->type = (int)$value;
481 }
482 break;
483 case 'montant_cotis':
484 $value = strtr($value, ',', '.');
485 if (!empty($value) || $value === '0') {
486 $this->_amount = (double)$value;
487 }
488 if (!is_numeric($value) && $value !== '') {
489 $this->errors[] = _T("- The amount must be an integer!");
490 }
491 break;
492 case 'type_paiement_cotis':
493 $ptypes = new PaymentTypes(
494 $this->zdb,
495 $preferences,
496 $this->login
497 );
498 $ptlist = $ptypes->getList();
499 if (isset($ptlist[$value])) {
500 $this->_payment_type = $value;
501 } else {
502 $this->errors[] = _T("- Unknown payment type");
503 }
504 break;
505 case 'info_cotis':
506 $this->_info = $value;
507 break;
508 case Transaction::PK:
509 if ($value != '') {
510 $this->_transaction = new Transaction($this->zdb, $this->login, (int)$value);
511 }
512 break;
513 case 'duree_mois_cotis':
514 if ($value != '') {
515 if (!is_numeric($value) || $value <= 0) {
516 $this->errors[] = _T("- The duration must be a positive integer!");
517 }
518 $this->$prop = $value;
519 $this->retrieveEndDate();
520 }
521 break;
522 }
523 }
524 }
525
526 // missing required fields?
527 foreach ($required as $key => $val) {
528 if ($val === 1) {
529 $prop = '_' . $this->_fields[$key]['propname'];
530 if (
531 !isset($disabled[$key])
532 && (!isset($this->$prop)
533 || (!is_object($this->$prop) && trim($this->$prop) == '')
534 || (is_object($this->$prop) && trim($this->$prop->id) == ''))
535 ) {
536 $this->errors[] = str_replace(
537 '%field',
538 '<a href="#' . $key . '">' . $this->getFieldLabel($key) . '</a>',
539 _T("- Mandatory field %field empty.")
540 );
541 }
542 }
543 }
544
545 if ($this->_transaction != null && $this->_amount != null) {
546 $missing = $this->_transaction->getMissingAmount();
547 //calculate new missing amount
548 $missing = $missing + $this->_orig_amount - $this->_amount;
549 if ($missing < 0) {
550 $this->errors[] = _T("- Sum of all contributions exceed corresponding transaction amount.");
551 }
552 }
553
554 if ($this->isFee() && count($this->errors) == 0) {
555 $overlap = $this->checkOverlap();
556 if ($overlap !== true) {
557 //method directly return error message
558 $this->errors[] = $overlap;
559 }
560 }
561
562 $this->dynamicsCheck($values, $required, $disabled);
563
564 if (count($this->errors) > 0) {
565 Analog::log(
566 'Some errors has been threw attempting to edit/store a contribution' .
567 print_r($this->errors, true),
568 Analog::ERROR
569 );
570 return $this->errors;
571 } else {
572 Analog::log(
573 'Contribution checked successfully.',
574 Analog::DEBUG
575 );
576 return true;
577 }
578 }
579
580 /**
581 * Check that membership fees does not overlap
582 *
583 * @return boolean|string True if all is ok, false if error,
584 * error message if overlap
585 */
586 public function checkOverlap()
587 {
588 try {
589 $select = $this->zdb->select(self::TABLE, 'c');
590 //@phpstan-ignore-next-line
591 $select->columns(
592 array('date_debut_cotis', 'date_fin_cotis')
593 )->join(
594 array('ct' => PREFIX_DB . ContributionsTypes::TABLE),
595 'c.' . ContributionsTypes::PK . '=ct.' . ContributionsTypes::PK,
596 array()
597 )->where([Adherent::PK => $this->_member])
598 ->where(array('cotis_extension' => new Expression('true')))
599 ->where->nest->nest
600 ->greaterThanOrEqualTo('date_debut_cotis', $this->_begin_date)
601 ->lessThanOrEqualTo('date_debut_cotis', $this->_end_date)
602 ->unnest
603 ->or->nest
604 ->greaterThanOrEqualTo('date_fin_cotis', $this->_begin_date)
605 ->lessThanOrEqualTo('date_fin_cotis', $this->_end_date);
606
607 if ($this->id != '') {
608 $select->where->notEqualTo(self::PK, $this->id);
609 }
610
611 $results = $this->zdb->execute($select);
612 if ($results->count() > 0) {
613 $result = $results->current();
614
615 $d_begin = new \DateTime($result->date_debut_cotis);
616 $d_end = new \DateTime($result->date_fin_cotis);
617
618 if ($d_begin->format('m-d') == $d_end->format('m-d') && $result->date_fin_cotis == $this->_begin_date) {
619 //see https://bugs.galette.eu/issues/1762
620 return true;
621 }
622
623 return _T("- Membership period overlaps period starting at ") .
624 $d_begin->format(__("Y-m-d"));
625 }
626 return true;
627 } catch (Throwable $e) {
628 Analog::log(
629 'An error occurred checking overlapping fee. ' . $e->getMessage(),
630 Analog::ERROR
631 );
632 throw $e;
633 }
634 }
635
636 /**
637 * Store the contribution
638 *
639 * @return boolean
640 */
641 public function store()
642 {
643 global $hist, $emitter;
644
645 $event = null;
646
647 if (count($this->errors) > 0) {
648 throw new \RuntimeException(
649 'Existing errors prevents storing contribution: ' .
650 print_r($this->errors, true)
651 );
652 }
653
654 try {
655 $this->zdb->connection->beginTransaction();
656 $values = array();
657 $fields = self::getDbFields($this->zdb);
658 foreach ($fields as $field) {
659 $prop = '_' . $this->_fields[$field]['propname'];
660 switch ($field) {
661 case ContributionsTypes::PK:
662 case Transaction::PK:
663 if (isset($this->$prop)) {
664 $values[$field] = $this->$prop->id;
665 }
666 break;
667 default:
668 $values[$field] = $this->$prop;
669 break;
670 }
671 }
672
673 //no end date, let's take database defaults
674 if (!$this->isFee() && !$this->_end_date) {
675 unset($values['date_fin_cotis']);
676 }
677
678 if (!isset($this->_id) || $this->_id == '') {
679 //we're inserting a new contribution
680 unset($values[self::PK]);
681
682 $insert = $this->zdb->insert(self::TABLE);
683 $insert->values($values);
684 $add = $this->zdb->execute($insert);
685
686 if ($add->count() > 0) {
687 $this->_id = $this->zdb->getLastGeneratedValue($this);
688
689 // logging
690 $hist->add(
691 _T("Contribution added"),
692 Adherent::getSName($this->zdb, $this->_member)
693 );
694 $event = $this->getAddEventName();
695 } else {
696 $hist->add(_T("Fail to add new contribution."));
697 throw new \Exception(
698 'An error occurred inserting new contribution!'
699 );
700 }
701 } else {
702 //we're editing an existing contribution
703 $update = $this->zdb->update(self::TABLE);
704 $update->set($values)->where([self::PK => $this->_id]);
705 $edit = $this->zdb->execute($update);
706
707 //edit == 0 does not mean there were an error, but that there
708 //were nothing to change
709 if ($edit->count() > 0) {
710 $hist->add(
711 _T("Contribution updated"),
712 Adherent::getSName($this->zdb, $this->_member)
713 );
714 }
715
716 $event = $this->getEditEventName();
717 }
718 //update deadline
719 if ($this->isFee()) {
720 $this->updateDeadline();
721 }
722
723 //dynamic fields
724 $this->dynamicsStore(true);
725
726 $this->zdb->connection->commit();
727 $this->_orig_amount = $this->_amount;
728
729 //send event at the end of process, once all has been stored
730 if ($event !== null && $this->areEventsEnabled()) {
731 $emitter->dispatch(new GaletteEvent($event, $this));
732 }
733
734 return true;
735 } catch (Throwable $e) {
736 if ($this->zdb->connection->inTransaction()) {
737 $this->zdb->connection->rollBack();
738 }
739 throw $e;
740 }
741 }
742
743 /**
744 * Update member dead line
745 *
746 * @return boolean
747 */
748 private function updateDeadline()
749 {
750 try {
751 $due_date = self::getDueDate($this->zdb, $this->_member);
752
753 if ($due_date != '') {
754 $due_date_update = $due_date;
755 } else {
756 $due_date_update = new Expression('NULL');
757 }
758
759 $update = $this->zdb->update(Adherent::TABLE);
760 $update->set(
761 array('date_echeance' => $due_date_update)
762 )->where(
763 [Adherent::PK => $this->_member]
764 );
765 $this->zdb->execute($update);
766 return true;
767 } catch (Throwable $e) {
768 Analog::log(
769 'An error occurred updating member ' . $this->_member .
770 '\'s deadline |' .
771 $e->getMessage(),
772 Analog::ERROR
773 );
774 throw $e;
775 }
776 }
777
778 /**
779 * Remove contribution from database
780 *
781 * @param boolean $transaction Activate transaction mode (defaults to true)
782 *
783 * @return boolean
784 */
785 public function remove($transaction = true)
786 {
787 global $emitter;
788
789 try {
790 if ($transaction) {
791 $this->zdb->connection->beginTransaction();
792 }
793
794 $delete = $this->zdb->delete(self::TABLE);
795 $delete->where([self::PK => $this->_id]);
796 $del = $this->zdb->execute($delete);
797 if ($del->count() > 0) {
798 $this->updateDeadline();
799 $this->dynamicsRemove(true);
800 } else {
801 Analog::log(
802 'Contribution has not been removed!',
803 Analog::WARNING
804 );
805 return false;
806 }
807 if ($transaction) {
808 $this->zdb->connection->commit();
809 }
810 $emitter->dispatch(new GaletteEvent('contribution.remove', $this));
811 return true;
812 } catch (Throwable $e) {
813 if ($transaction) {
814 $this->zdb->connection->rollBack();
815 }
816 Analog::log(
817 'An error occurred trying to remove contribution #' .
818 $this->_id . ' | ' . $e->getMessage(),
819 Analog::ERROR
820 );
821 throw $e;
822 }
823 }
824
825 /**
826 * Get field label
827 *
828 * @param string $field Field name
829 *
830 * @return string
831 */
832 public function getFieldLabel($field)
833 {
834 $label = $this->_fields[$field]['label'];
835 if ($this->isFee() && $field == 'date_debut_cotis') {
836 $label = $this->_fields[$field]['cotlabel'];
837 }
838 //replace "&nbsp;"
839 $label = str_replace('&nbsp;', ' ', $label);
840 //remove trailing ':' and then trim
841 $label = trim(trim($label, ':'));
842 return $label;
843 }
844
845 /**
846 * Retrieve fields from database
847 *
848 * @param Db $zdb Database instance
849 *
850 * @return array
851 */
852 public static function getDbFields(Db $zdb)
853 {
854 $columns = $zdb->getColumns(self::TABLE);
855 $fields = array();
856 foreach ($columns as $col) {
857 $fields[] = $col->getName();
858 }
859 return $fields;
860 }
861
862 /**
863 * Get the relevant CSS class for current contribution
864 *
865 * @return string current contribution row class
866 */
867 public function getRowClass()
868 {
869 return ($this->_end_date != $this->_begin_date && $this->_is_cotis) ?
870 'cotis-normal' : 'cotis-give';
871 }
872
873 /**
874 * Retrieve member due date
875 *
876 * @param Db $zdb Database instance
877 * @param integer $member_id Member identifier
878 *
879 * @return string
880 */
881 public static function getDueDate(Db $zdb, $member_id)
882 {
883 if (!$member_id) {
884 return '';
885 }
886 try {
887 $select = $zdb->select(self::TABLE, 'c');
888 $select->columns(
889 array(
890 'max_date' => new Expression('MAX(date_fin_cotis)')
891 )
892 )->join(
893 array('ct' => PREFIX_DB . ContributionsTypes::TABLE),
894 'c.' . ContributionsTypes::PK . '=ct.' . ContributionsTypes::PK,
895 array()
896 )->where(
897 [Adherent::PK => $member_id]
898 )->where(
899 array('cotis_extension' => new Expression('true'))
900 );
901
902 $results = $zdb->execute($select);
903 $result = $results->current();
904 $due_date = $result->max_date;
905
906 //avoid bad dates in postgres and bad mysql return from zenddb
907 if ($due_date == '0001-01-01 BC' || $due_date == '1901-01-01') {
908 $due_date = '';
909 }
910 return $due_date;
911 } catch (Throwable $e) {
912 Analog::log(
913 'An error occurred trying to retrieve member\'s due date',
914 Analog::ERROR
915 );
916 throw $e;
917 }
918 }
919
920 /**
921 * Detach a contribution from a transaction
922 *
923 * @param Db $zdb Database instance
924 * @param Login $login Login instance
925 * @param int $trans_id Transaction identifier
926 * @param int $contrib_id Contribution identifier
927 *
928 * @return boolean
929 */
930 public static function unsetTransactionPart(Db $zdb, Login $login, $trans_id, $contrib_id)
931 {
932 try {
933 //first, we check if contribution is part of transaction
934 $c = new Contribution($zdb, $login, (int)$contrib_id);
935 if ($c->isTransactionPartOf($trans_id)) {
936 $update = $zdb->update(self::TABLE);
937 $update->set(
938 array(Transaction::PK => null)
939 )->where(
940 [self::PK => $contrib_id]
941 );
942 $zdb->execute($update);
943 return true;
944 } else {
945 Analog::log(
946 'Contribution #' . $contrib_id .
947 ' is not actually part of transaction #' . $trans_id,
948 Analog::WARNING
949 );
950 return false;
951 }
952 } catch (Throwable $e) {
953 Analog::log(
954 'Unable to detach contribution #' . $contrib_id .
955 ' to transaction #' . $trans_id . ' | ' . $e->getMessage(),
956 Analog::ERROR
957 );
958 throw $e;
959 }
960 }
961
962 /**
963 * Set a contribution as a transaction part
964 *
965 * @param Db $zdb Database instance
966 * @param int $trans_id Transaction identifier
967 * @param int $contrib_id Contribution identifier
968 *
969 * @return boolean
970 */
971 public static function setTransactionPart(Db $zdb, $trans_id, $contrib_id)
972 {
973 try {
974 $update = $zdb->update(self::TABLE);
975 $update->set(
976 array(Transaction::PK => $trans_id)
977 )->where([self::PK => $contrib_id]);
978
979 $zdb->execute($update);
980 return true;
981 } catch (Throwable $e) {
982 Analog::log(
983 'Unable to attach contribution #' . $contrib_id .
984 ' to transaction #' . $trans_id . ' | ' . $e->getMessage(),
985 Analog::ERROR
986 );
987 throw $e;
988 }
989 }
990
991 /**
992 * Is current contribution a membership fee
993 *
994 * @return boolean
995 */
996 public function isFee()
997 {
998 return $this->_is_cotis;
999 }
1000
1001 /**
1002 * Is current contribution part of specified transaction
1003 *
1004 * @param int $id Transaction identifier
1005 *
1006 * @return boolean
1007 */
1008 public function isTransactionPartOf($id)
1009 {
1010 if ($this->isTransactionPart()) {
1011 return $id == $this->_transaction->id;
1012 } else {
1013 return false;
1014 }
1015 }
1016
1017 /**
1018 * Is current contribution part of transaction
1019 *
1020 * @return boolean
1021 */
1022 public function isTransactionPart()
1023 {
1024 return $this->_transaction != null;
1025 }
1026
1027 /**
1028 * Execute post contribution script
1029 *
1030 * @param ExternalScript $es External script to execute
1031 * @param array $extra Extra information on contribution
1032 * Defaults to null
1033 * @param array $pextra Extra information on payment
1034 * Defaults to null
1035 *
1036 * @return mixed Script return value on success, values and script output on fail
1037 */
1038 public function executePostScript(
1039 ExternalScript $es,
1040 $extra = null,
1041 $pextra = null
1042 ) {
1043 global $preferences;
1044
1045 $payment = array(
1046 'type' => $this->getPaymentType()
1047 );
1048
1049 if ($pextra !== null && is_array($pextra)) {
1050 $payment = array_merge($payment, $pextra);
1051 }
1052
1053 if (!file_exists(GALETTE_CACHE_DIR . '/pdf_contribs')) {
1054 @mkdir(GALETTE_CACHE_DIR . '/pdf_contribs');
1055 }
1056
1057 $voucher_path = null;
1058 if ($this->_id !== null) {
1059 $voucher = new PdfContribution($this, $this->zdb, $preferences);
1060 $voucher->store(GALETTE_CACHE_DIR . '/pdf_contribs');
1061 $voucher_path = $voucher->getPath();
1062 }
1063
1064 $contrib = array(
1065 'id' => (int)$this->_id,
1066 'date' => $this->_date,
1067 'type' => $this->getRawType(),
1068 'amount' => $this->amount,
1069 'voucher' => $voucher_path,
1070 'category' => array(
1071 'id' => $this->type->id,
1072 'label' => $this->type->libelle
1073 ),
1074 'payment' => $payment
1075 );
1076
1077 if ($this->_member !== null) {
1078 $m = new Adherent($this->zdb, (int)$this->_member);
1079 $member = array(
1080 'id' => (int)$this->_member,
1081 'name' => $m->sfullname,
1082 'email' => $m->email,
1083 'organization' => ($m->isCompany() ? 1 : 0),
1084 'status' => array(
1085 'id' => $m->status,
1086 'label' => $m->sstatus
1087 ),
1088 'country' => $m->country
1089 );
1090
1091 if ($m->isCompany()) {
1092 $member['organization_name'] = $m->company_name;
1093 }
1094
1095 $contrib['member'] = $member;
1096 }
1097
1098 if ($extra !== null && is_array($extra)) {
1099 $contrib = array_merge($contrib, $extra);
1100 }
1101
1102 $res = $es->send($contrib);
1103
1104 if ($res !== true) {
1105 Analog::log(
1106 'An error occurred calling post contribution ' .
1107 "script:\n" . $es->getOutput(),
1108 Analog::ERROR
1109 );
1110 $res = _T("Contribution information") . "\n";
1111 $res .= print_r($contrib, true);
1112 $res .= "\n\n" . _T("Script output") . "\n";
1113 $res .= $es->getOutput();
1114 }
1115
1116 return $res;
1117 }
1118 /**
1119 * Get raw contribution type
1120 *
1121 * @return string
1122 */
1123 public function getRawType()
1124 {
1125 if ($this->isFee()) {
1126 return 'membership';
1127 } else {
1128 return 'donation';
1129 }
1130 }
1131
1132 /**
1133 * Get contribution type label
1134 *
1135 * @return string
1136 */
1137 public function getTypeLabel()
1138 {
1139 if ($this->isFee()) {
1140 return _T("Membership");
1141 } else {
1142 return _T("Donation");
1143 }
1144 }
1145
1146 /**
1147 * Get payment type label
1148 *
1149 * @param boolean $translated Whether to translate
1150 *
1151 * @return string
1152 */
1153 public function getPaymentType(bool $translated = false): string
1154 {
1155 if ($this->_payment_type === null) {
1156 return '-';
1157 }
1158
1159 $ptype = new PaymentType($this->zdb, (int)$this->payment_type);
1160 return $ptype->getName($translated);
1161 }
1162
1163 /**
1164 * Global getter method
1165 *
1166 * @param string $name name of the property we want to retrieve
1167 *
1168 * @return mixed the called property
1169 */
1170 public function __get($name)
1171 {
1172
1173 $forbidden = array('is_cotis');
1174 $virtuals = array('duration', 'spayment_type', 'model', 'raw_date',
1175 'raw_begin_date', 'raw_end_date'
1176 );
1177
1178 $rname = '_' . $name;
1179
1180 if (in_array($name, $forbidden)) {
1181 Analog::log(
1182 "Call to __get for '$name' is forbidden!",
1183 Analog::WARNING
1184 );
1185
1186 switch ($name) {
1187 case 'is_cotis':
1188 return $this->isFee();
1189 default:
1190 throw new \RuntimeException("Call to __get for '$name' is forbidden!");
1191 }
1192 } elseif (
1193 property_exists($this, $rname)
1194 || in_array($name, $virtuals)
1195 ) {
1196 switch ($name) {
1197 case 'raw_date':
1198 case 'raw_begin_date':
1199 case 'raw_end_date':
1200 $rname = '_' . substr($name, 4);
1201 if ($this->$rname !== null && $this->$rname != '') {
1202 try {
1203 $d = new \DateTime($this->$rname);
1204 return $d;
1205 } catch (Throwable $e) {
1206 //oops, we've got a bad date :/
1207 Analog::log(
1208 'Bad date (' . $this->$rname . ') | ' .
1209 $e->getMessage(),
1210 Analog::INFO
1211 );
1212 throw $e;
1213 }
1214 }
1215 break;
1216 case 'date':
1217 case 'begin_date':
1218 case 'end_date':
1219 if ($this->$rname !== null && $this->$rname != '') {
1220 try {
1221 $d = new \DateTime($this->$rname);
1222 return $d->format(__("Y-m-d"));
1223 } catch (Throwable $e) {
1224 //oops, we've got a bad date :/
1225 Analog::log(
1226 'Bad date (' . $this->$rname . ') | ' .
1227 $e->getMessage(),
1228 Analog::INFO
1229 );
1230 return $this->$rname;
1231 }
1232 }
1233 break;
1234 case 'duration':
1235 if ($this->_is_cotis) {
1236 // Caution : the end_date stored is actually the due date.
1237 // Adding a day to compute the next_begin_date is required
1238 // to return the right number of months.
1239 $next_begin_date = new \DateTime($this->_end_date ?? $this->_begin_date);
1240 $next_begin_date->add(new \DateInterval('P1D'));
1241 $begin_date = new \DateTime($this->_begin_date);
1242 $diff = $next_begin_date->diff($begin_date);
1243 return (int)$diff->format('%y') * 12 + (int)$diff->format('%m');
1244 } else {
1245 return '';
1246 }
1247 case 'spayment_type':
1248 return $this->getPaymentType(true);
1249 case 'model':
1250 if ($this->_is_cotis === null) {
1251 return null;
1252 }
1253 return ($this->isFee()) ?
1254 PdfModel::INVOICE_MODEL : PdfModel::RECEIPT_MODEL;
1255 default:
1256 return $this->$rname;
1257 }
1258 } else {
1259 Analog::log(
1260 "Unknown property '$rname'",
1261 Analog::WARNING
1262 );
1263 return null;
1264 }
1265 }
1266
1267 /**
1268 * Global isset method
1269 * Required for twig to access properties via __get
1270 *
1271 * @param string $name name of the property we want to retrieve
1272 *
1273 * @return bool
1274 */
1275 public function __isset($name)
1276 {
1277 $forbidden = array('is_cotis');
1278 $virtuals = array('duration', 'spayment_type', 'model', 'raw_date',
1279 'raw_begin_date', 'raw_end_date'
1280 );
1281
1282 $rname = '_' . $name;
1283
1284 if (in_array($name, $forbidden)) {
1285 switch ($name) {
1286 case 'is_cotis':
1287 return true;
1288 }
1289 } elseif (
1290 property_exists($this, $rname)
1291 || in_array($name, $virtuals)
1292 ) {
1293 return true;
1294 }
1295
1296 return false;
1297 }
1298
1299
1300 /**
1301 * Global setter method
1302 *
1303 * @param string $name name of the property we want to assign a value to
1304 * @param mixed $value a relevant value for the property
1305 *
1306 * @return void
1307 */
1308 public function __set($name, $value)
1309 {
1310 global $preferences;
1311
1312 $forbidden = array('fields', 'is_cotis', 'end_date');
1313
1314 if (!in_array($name, $forbidden)) {
1315 $rname = '_' . $name;
1316 switch ($name) {
1317 case 'transaction':
1318 if (is_int($value)) {
1319 $this->$rname = new Transaction($this->zdb, $this->login, $value);
1320 } else {
1321 Analog::log(
1322 'Trying to set a transaction from an id that is not an integer.',
1323 Analog::WARNING
1324 );
1325 }
1326 break;
1327 case 'type':
1328 if (is_int($value)) {
1329 //set type
1330 $this->$rname = new ContributionsTypes($this->zdb, $value);
1331 //set is_cotis according to type
1332 if ($this->$rname->extension == 1) {
1333 $this->_is_cotis = true;
1334 } else {
1335 $this->_is_cotis = false;
1336 }
1337 } else {
1338 Analog::log(
1339 'Trying to set a type from an id that is not an integer.',
1340 Analog::WARNING
1341 );
1342 }
1343 break;
1344 case 'begin_date':
1345 try {
1346 $d = \DateTime::createFromFormat(__("Y-m-d"), $value);
1347 if ($d === false) {
1348 throw new \Exception('Incorrect format');
1349 }
1350 $this->_begin_date = $d->format('Y-m-d');
1351 } catch (Throwable $e) {
1352 Analog::log(
1353 'Wrong date format. field: ' . $name .
1354 ', value: ' . $value . ', expected fmt: ' .
1355 __("Y-m-d") . ' | ' . $e->getMessage(),
1356 Analog::INFO
1357 );
1358 $this->errors[] = str_replace(
1359 array(
1360 '%date_format',
1361 '%field'
1362 ),
1363 array(
1364 __("Y-m-d"),
1365 $this->_fields['date_debut_cotis']['label']
1366 ),
1367 _T("- Wrong date format (%date_format) for %field!")
1368 );
1369 }
1370 break;
1371 case 'amount':
1372 if (is_numeric($value) && $value > 0) {
1373 $this->$rname = $value;
1374 } else {
1375 Analog::log(
1376 'Trying to set an amount with a non numeric value, ' .
1377 'or with a zero value',
1378 Analog::WARNING
1379 );
1380 }
1381 break;
1382 case 'member':
1383 if (is_int($value)) {
1384 //set type
1385 $this->$rname = $value;
1386 }
1387 break;
1388 case 'payment_type':
1389 $ptypes = new PaymentTypes(
1390 $this->zdb,
1391 $preferences,
1392 $this->login
1393 );
1394 $list = $ptypes->getList();
1395 if (isset($list[$value])) {
1396 $this->_payment_type = $value;
1397 } else {
1398 Analog::log(
1399 'Unknown payment type ' . $value,
1400 Analog::WARNING
1401 );
1402 }
1403 break;
1404 default:
1405 Analog::log(
1406 '[' . __CLASS__ . ']: Trying to set an unknown property (' .
1407 $name . ')',
1408 Analog::WARNING
1409 );
1410 break;
1411 }
1412 }
1413 }
1414
1415 /**
1416 * Flag creation mail sending
1417 *
1418 * @param boolean $send True (default) to send creation email
1419 *
1420 * @return Contribution
1421 */
1422 public function setSendmail(bool $send = true)
1423 {
1424 $this->sendmail = $send;
1425 return $this;
1426 }
1427
1428 /**
1429 * Should we send administrative emails to member?
1430 *
1431 * @return boolean
1432 */
1433 public function sendEMail()
1434 {
1435 return $this->sendmail;
1436 }
1437
1438 /**
1439 * Handle files (dynamics files)
1440 *
1441 * @param array $files Files sent
1442 *
1443 * @return array|true
1444 */
1445 public function handleFiles($files)
1446 {
1447 $this->errors = [];
1448
1449 $this->dynamicsFiles($files);
1450
1451 if (count($this->errors) > 0) {
1452 Analog::log(
1453 'Some errors has been threw attempting to edit/store a contribution files' . "\n" .
1454 print_r($this->errors, true),
1455 Analog::ERROR
1456 );
1457 return $this->errors;
1458 } else {
1459 return true;
1460 }
1461 }
1462
1463 /**
1464 * Get required fields list
1465 *
1466 * @return array
1467 */
1468 public function getRequired(): array
1469 {
1470 return [
1471 'id_type_cotis' => 1,
1472 'id_adh' => 1,
1473 'date_enreg' => 1,
1474 'date_debut_cotis' => 1,
1475 'date_fin_cotis' => $this->isFee() ? 1 : 0,
1476 'montant_cotis' => $this->isFee() ? 1 : 0
1477 ];
1478 }
1479
1480 /**
1481 * Can current logged-in user display contribution
1482 *
1483 * @param Login $login Login instance
1484 *
1485 * @return boolean
1486 */
1487 public function canShow(Login $login): bool
1488 {
1489 //non-logged-in members cannot show contributions
1490 if (!$login->isLogged()) {
1491 return false;
1492 }
1493
1494 //admin and staff users can edit, as well as member itself
1495 if (!$this->id || $login->id == $this->_member || $login->isAdmin() || $login->isStaff()) {
1496 return true;
1497 }
1498
1499 //parent can see their children contributions
1500 $parent = new Adherent($this->zdb);
1501 $parent
1502 ->disableAllDeps()
1503 ->enableDep('children')
1504 ->load($this->login->id);
1505 if ($parent->hasChildren()) {
1506 foreach ($parent->children as $child) {
1507 if ($child->id === $this->_member) {
1508 return true;
1509 }
1510 }
1511 return false;
1512 }
1513
1514 return false;
1515 }
1516
1517 /**
1518 * Get prefix for events
1519 *
1520 * @return string
1521 */
1522 protected function getEventsPrefix(): string
1523 {
1524 return 'contribution';
1525 }
1526 }