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