Merge pull request #24178 from demeritcowboy/php81-frontend3
[civicrm-core.git] / CRM / Financial / BAO / Order.php
CommitLineData
7aa78908 1<?php
2/*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
4df23f2a 12use Civi\Api4\LineItem;
58315149 13use Civi\Api4\PriceField;
60e5cf34 14use Civi\Api4\PriceFieldValue;
812b2c0c 15use Civi\Api4\PriceSet;
58315149 16
7aa78908 17/**
18 *
19 * @package CRM
20 * @copyright CiviCRM LLC https://civicrm.org/licensing
20df462f 21 *
7aa78908 22 * Order class.
23 *
24 * This class is intended to become the object to manage orders, including via Order.api.
25 *
26 * As of writing it is in the process of having appropriate functions built up.
20df462f 27 * It should **NOT** be accessed directly outside of tested core methods as it
28 * may change.
812b2c0c
EM
29 *
30 * @internal
7aa78908 31 */
32class CRM_Financial_BAO_Order {
33
34 /**
35 * Price set id.
36 *
37 * @var int
38 */
39 protected $priceSetID;
40
41 /**
42 * Selected price items in the format we see in forms.
43 *
44 * ie.
45 * [price_3 => 4, price_10 => 7]
46 * is equivalent to 'option_value 4 for radio price field 3 and
47 * a quantity of 7 for text price field 10.
48 *
49 * @var array
50 */
51 protected $priceSelection = [];
52
53 /**
54 * Override for financial type id.
55 *
56 * Used when the financial type id is to be overridden for all line items
57 * (as can happen in backoffice forms)
58 *
59 * @var int
60 */
61 protected $overrideFinancialTypeID;
62
4df23f2a
EM
63 /**
64 * Overridable financial type id.
65 *
66 * When this is set only this financial type will be overridden.
67 *
68 * This is relevant to repeat transactions where we want to
69 * override the type on the line items if it a different financial type has
70 * been saved against the recurring contribution. However, it the line item
71 * financial type differs from the contribution financial type then we
72 * treat this as deliberately uncoupled and don't flow through changes
73 * in financial type down to the line items.
74 *
75 * This is covered in testRepeatTransactionUpdatedFinancialTypeAndNotEquals.
76 *
77 * @var int
78 */
79 protected $overridableFinancialTypeID;
80
81 /**
82 * Get overridable financial type id.
83 *
84 * If only one financial type id can be overridden at the line item level
85 * then get it here, otherwise NULL.
86 *
87 * @return int|null
88 */
89 public function getOverridableFinancialTypeID(): ?int {
90 return $this->overridableFinancialTypeID;
91 }
92
93 /**
94 * Set overridable financial type id.
95 *
96 * If only one financial type id can be overridden at the line item level
97 * then set it here.
98 *
99 * @param int|null $overridableFinancialTypeID
100 */
101 public function setOverridableFinancialTypeID(?int $overridableFinancialTypeID): void {
102 $this->overridableFinancialTypeID = $overridableFinancialTypeID;
103 }
104
ca44bb7e
EM
105 /**
106 * Financial type id to use for any lines where is is not provided.
107 *
108 * @var int
109 */
110 protected $defaultFinancialTypeID;
111
4df23f2a
EM
112 /**
113 * ID of a contribution to be used as a template.
114 *
115 * @var int
116 */
117 protected $templateContributionID;
118
119 /**
120 * Should we permit the line item financial type to be overridden when there is more than one line.
121 *
122 * Historically the answer is 'yes' for v3 order api and 'no' for repeattransaction
123 * and backoffice forms.
124 *
125 * @var bool
126 */
e5e212b5 127 protected $isPermitOverrideFinancialTypeForMultipleLines = FALSE;
4df23f2a
EM
128
129 /**
130 * @return bool
131 */
132 public function isPermitOverrideFinancialTypeForMultipleLines(): bool {
133 return $this->isPermitOverrideFinancialTypeForMultipleLines;
134 }
135
136 /**
137 * @param bool $isPermitOverrideFinancialTypeForMultipleLines
138 */
139 public function setIsPermitOverrideFinancialTypeForMultipleLines(bool $isPermitOverrideFinancialTypeForMultipleLines): void {
140 $this->isPermitOverrideFinancialTypeForMultipleLines = $isPermitOverrideFinancialTypeForMultipleLines;
141 }
142
143 /**
144 * Number of line items.
145 *
146 * @var int
147 */
148 protected $lineItemCount;
149
150 /**
151 * @return int
152 */
153 public function getLineItemCount(): int {
154 if (!isset($this->lineItemCount)) {
155 $this->lineItemCount = count($this->getPriceOptions()) || count($this->lineItems);
156 }
157 return $this->lineItemCount;
158 }
159
160 /**
161 * @param int $lineItemCount
162 */
163 public function setLineItemCount(int $lineItemCount): void {
164 $this->lineItemCount = $lineItemCount;
165 }
166
167 /**
168 * @return int|null
169 */
170 public function getTemplateContributionID(): ?int {
171 return $this->templateContributionID;
172 }
173
174 /**
175 * @param int $templateContributionID
176 */
177 public function setTemplateContributionID(int $templateContributionID): void {
178 $this->templateContributionID = $templateContributionID;
179 }
180
ca44bb7e
EM
181 /**
182 * @return int
183 */
184 public function getDefaultFinancialTypeID(): int {
185 return $this->defaultFinancialTypeID;
186 }
187
188 /**
189 * Set the default financial type id to be used when the line has none.
190 *
191 * @param int|null $defaultFinancialTypeID
192 */
193 public function setDefaultFinancialTypeID(?int $defaultFinancialTypeID): void {
194 $this->defaultFinancialTypeID = $defaultFinancialTypeID;
195 }
196
7aa78908 197 /**
198 * Override for the total amount of the order.
199 *
200 * When there is a single line item the order total may be overriden.
201 *
202 * @var float
203 */
204 protected $overrideTotalAmount;
205
206 /**
207 * Line items in the order.
208 *
209 * @var array
210 */
211 protected $lineItems = [];
212
ca1b238b
EM
213 /**
214 * Array of entities ordered.
215 *
216 * @var array
217 */
218 protected $entityParameters = [];
219
220 /**
221 * Default price sets for component.
222 *
223 * @var array
224 */
225 protected $defaultPriceSets = [];
226
227 /**
228 * Cache of the default price field.
229 *
230 * @var array
231 */
232 protected $defaultPriceField;
233
60e5cf34
EM
234 /**
235 * Cache of the default price field value ID.
236 *
237 * @var array
238 */
239 protected $defaultPriceFieldValueID;
240
ca1b238b
EM
241 /**
242 * Get parameters for the entities bought as part of this order.
243 *
244 * @return array
245 *
246 * @internal core tested code only.
247 *
248 */
249 public function getEntitiesToCreate(): array {
250 $entities = [];
251 foreach ($this->entityParameters as $entityToCreate) {
252 if (in_array($entityToCreate['entity'], ['participant', 'membership'], TRUE)) {
253 $entities[] = $entityToCreate;
254 }
255 }
256 return $entities;
257 }
258
259 /**
260 * Set parameters for the entities bought as part of this order.
261 *
262 * @param array $entityParameters
263 * @param int|string $key indexing reference
264 *
265 * @internal core tested code only.
266 *
267 */
268 public function setEntityParameters(array $entityParameters, $key): void {
269 $this->entityParameters[$key] = $entityParameters;
270 }
271
272 /**
273 * Add a line item to an entity.
274 *
275 * The v3 api supports more than on line item being stored against a given
276 * set of entity parameters. There is some doubt as to whether this is a
277 * good thing that should be supported in v4 or something that 'seemed
278 * like a good idea at the time' - but this allows the lines to be added from the
279 * v3 api.
280 *
281 * @param string $lineIndex
282 * @param string $entityKey
283 */
284 public function addLineItemToEntityParameters(string $lineIndex, string $entityKey): void {
285 $this->entityParameters[$entityKey]['entity'] = $this->getLineItemEntity($lineIndex);
286 $this->entityParameters[$entityKey]['line_references'][] = $lineIndex;
287 }
288
7aa78908 289 /**
290 * Metadata for price fields.
291 *
292 * @var array
293 */
294 protected $priceFieldMetadata = [];
295
8193ed74
EM
296 /**
297 * Metadata for price field values.
298 *
299 * @var array
300 */
301 protected $priceFieldValueMetadata = [];
302
be2e79c8 303 /**
304 * Metadata for price sets.
305 *
306 * @var array
307 */
308 protected $priceSetMetadata = [];
309
20df462f 310 /**
311 * Get form object.
312 *
812b2c0c
EM
313 * @internal use in tested core code only.
314 *
20df462f 315 * @return \CRM_Core_Form|NULL
316 */
317 public function getForm(): ?CRM_Core_Form {
318 return $this->form;
319 }
320
321 /**
322 * Set form object.
323 *
812b2c0c
EM
324 * @internal use in tested core code only.
325 *
2024d5b9 326 * @param \CRM_Core_Form|null $form
20df462f 327 */
328 public function setForm(?CRM_Core_Form $form): void {
329 $this->form = $form;
330 }
331
332 /**
333 * The serialize & unserialize functions are to prevent the form being serialized & stored.
334 *
335 * The form could be potentially large & circular.
336 *
337 * We simply serialize the values needed to re-serialize the form.
338 *
339 * @return array
340 */
341 public function _serialize(): array {
342 return [
343 'OverrideTotalAmount' => $this->getOverrideTotalAmount(),
344 'OverrideFinancialType' => $this->getOverrideFinancialTypeID(),
345 'PriceSelection' => $this->getPriceSelection(),
346 ];
347 }
348
349 /**
350 * Re-instantiate the the class with non-calculated variables.
351 *
352 * @param array $data
353 */
354 public function _unserialize(array $data): void {
355 foreach ($data as $key => $value) {
356 $fn = 'set' . $key;
357 $this->$fn($value);
358 }
359 }
360
361 /**
362 * Form object - if present the buildAmount hook will be called.
363 *
364 * @var \CRM_Member_Form_Membership|\CRM_Member_Form_MembershipRenewal
365 */
366 protected $form;
367
7aa78908 368 /**
369 * Get Set override for total amount of the order.
370 *
812b2c0c
EM
371 * @internal use in tested core code only.
372 *
7aa78908 373 * @return float|false
374 */
375 public function getOverrideTotalAmount() {
77274636
EM
376 // The override amount is only valid for quick config price sets where more
377 // than one field has not been selected.
4df23f2a 378 if (!$this->overrideTotalAmount || $this->getLineItemCount() > 1) {
7aa78908 379 return FALSE;
380 }
77274636 381 return $this->overrideTotalAmount;
7aa78908 382 }
383
4df23f2a
EM
384 /**
385 * Is the line item financial type to be overridden.
386 *
387 * We have a tested scenario for repeatcontribution where the line item
388 * does not match the top level financial type for the order. In this case
389 * any financial type override has been determined to NOT apply to the line items.
390 *
391 * This is locked in via testRepeatTransactionUpdatedFinancialTypeAndNotEquals.
392 *
393 * @param int $financialTypeID
394 *
395 * @return bool
396 */
397 public function isOverrideLineItemFinancialType(int $financialTypeID) {
398 if (!$this->getOverrideFinancialTypeID()) {
399 return FALSE;
400 }
401 if (!$this->getOverridableFinancialTypeID()) {
402 return TRUE;
403 }
404 return $this->getOverridableFinancialTypeID() === $financialTypeID;
405 }
406
7aa78908 407 /**
408 * Set override for total amount.
409 *
812b2c0c
EM
410 * @internal use in tested core code only.
411 *
4df23f2a 412 * @param float|null $overrideTotalAmount
7aa78908 413 */
4df23f2a 414 public function setOverrideTotalAmount(?float $overrideTotalAmount): void {
77274636 415 $this->overrideTotalAmount = $overrideTotalAmount;
7aa78908 416 }
417
418 /**
419 * Get override for total amount.
420 *
812b2c0c
EM
421 * @internal use in tested core code only.
422 *
7aa78908 423 * @return int| FALSE
424 */
425 public function getOverrideFinancialTypeID() {
4df23f2a
EM
426 // We don't permit overrides if there is more than one line.
427 // The reason for this constraint may be more historical since
428 // the case could be made that if it is set it should be used and
429 // we have built out the tax calculations a lot now.
430 if (!$this->isPermitOverrideFinancialTypeForMultipleLines() && $this->getLineItemCount() > 1) {
7aa78908 431 return FALSE;
432 }
433 return $this->overrideFinancialTypeID ?? FALSE;
434 }
435
436 /**
437 * Set override for financial type ID.
438 *
812b2c0c
EM
439 * @internal use in tested core code only.
440 *
4df23f2a 441 * @param int|null $overrideFinancialTypeID
7aa78908 442 */
4df23f2a 443 public function setOverrideFinancialTypeID(?int $overrideFinancialTypeID): void {
7aa78908 444 $this->overrideFinancialTypeID = $overrideFinancialTypeID;
445 }
446
447 /**
448 * Getter for price set id.
449 *
812b2c0c
EM
450 * @internal use in tested core code only.
451 *
7aa78908 452 * @return int
812b2c0c
EM
453 *
454 * @throws \API_Exception
7aa78908 455 */
456 public function getPriceSetID(): int {
812b2c0c
EM
457 if (!$this->priceSetID) {
458 foreach ($this->getPriceOptions() as $fieldID => $valueID) {
459 $this->setPriceSetIDFromSelectedField($fieldID);
460 }
461 }
7aa78908 462 return $this->priceSetID;
463 }
464
465 /**
466 * Setter for price set id.
467 *
812b2c0c
EM
468 * @internal use in tested core code only.
469 *
7aa78908 470 * @param int $priceSetID
471 */
472 public function setPriceSetID(int $priceSetID) {
473 $this->priceSetID = $priceSetID;
474 }
475
812b2c0c
EM
476 /**
477 * Set price set id to the default.
478 *
479 * @param string $component [membership|contribution]
480 *
481 * @throws \API_Exception
482 * @internal use in tested core code only.
483 */
484 public function setPriceSetToDefault(string $component): void {
ca1b238b 485 $this->priceSetID = $this->getDefaultPriceSetForComponent($component);
812b2c0c
EM
486 }
487
d6c86ce5
EM
488 /**
489 * Set price set ID based on the contribution page id.
490 *
812b2c0c
EM
491 * @internal use in tested core code only.
492 *
d6c86ce5
EM
493 * @param int $contributionPageID
494 *
d6c86ce5
EM
495 */
496 public function setPriceSetIDByContributionPageID(int $contributionPageID): void {
497 $this->setPriceSetIDByEntity('contribution_page', $contributionPageID);
498 }
499
500 /**
501 * Set price set ID based on the event id.
502 *
812b2c0c
EM
503 * @internal use in tested core code only.
504 *
d6c86ce5
EM
505 * @param int $eventID
506 *
507 * @throws \CiviCRM_API3_Exception
508 */
509 public function setPriceSetIDByEventPageID(int $eventID): void {
510 $this->setPriceSetIDByEntity('event', $eventID);
511 }
512
513 /**
514 * Set the price set id based on looking up the entity.
812b2c0c
EM
515 *
516 * @internal use in tested core code only.
517 *
d6c86ce5
EM
518 * @param string $entity
519 * @param int $id
520 *
521 */
522 protected function setPriceSetIDByEntity(string $entity, int $id): void {
523 $this->priceSetID = CRM_Price_BAO_PriceSet::getFor('civicrm_' . $entity, $id);
524 }
525
7aa78908 526 /**
527 * Getter for price selection.
528 *
812b2c0c
EM
529 * @internal use in tested core code only.
530 *
7aa78908 531 * @return array
532 */
533 public function getPriceSelection(): array {
534 return $this->priceSelection;
535 }
536
537 /**
538 * Setter for price selection.
539 *
812b2c0c
EM
540 * @internal use in tested core code only.
541 *
7aa78908 542 * @param array $priceSelection
543 */
544 public function setPriceSelection(array $priceSelection) {
545 $this->priceSelection = $priceSelection;
546 }
547
548 /**
549 * Price options the simplified price fields selections.
550 *
551 * ie. the 'price_' is stripped off the key name and the field ID
552 * is cast to an integer.
553 *
812b2c0c
EM
554 * @internal use in tested core code only.
555 *
7aa78908 556 * @return array
557 */
558 public function getPriceOptions():array {
559 $priceOptions = [];
560 foreach ($this->getPriceSelection() as $fieldName => $value) {
561 $fieldID = substr($fieldName, 6);
562 $priceOptions[(int) $fieldID] = $value;
563 }
564 return $priceOptions;
565 }
566
567 /**
568 * Get the metadata for the given field.
569 *
812b2c0c
EM
570 * @internal use in tested core code only.
571 *
7aa78908 572 * @param int $id
573 *
574 * @return array
7aa78908 575 */
576 public function getPriceFieldSpec(int $id) :array {
2408aa47 577 return $this->getPriceFieldsMetadata()[$id] ?? $this->getPriceFieldMetadata($id);
de668aa8 578 }
579
8193ed74
EM
580 /**
581 * Get the metadata for the given field value.
582 *
583 * @internal use in tested core code only.
584 *
585 * @param int $id
586 *
587 * @return array
588 */
589 public function getPriceFieldValueSpec(int $id) :array {
590 if (!isset($this->priceFieldValueMetadata[$id])) {
591 $this->priceFieldValueMetadata[$id] = PriceFieldValue::get(FALSE)->addWhere('id', '=', $id)->execute()->first();
592 }
593 return $this->priceFieldValueMetadata[$id];
594 }
595
de668aa8 596 /**
597 * Get the metadata for the fields in the price set.
598 *
812b2c0c
EM
599 * @internal use in tested core code only.
600 *
de668aa8 601 * @return array
602 */
603 public function getPriceFieldsMetadata(): array {
604 if (empty($this->priceFieldMetadata)) {
be2e79c8 605 $this->getPriceSetMetadata();
7aa78908 606 }
de668aa8 607 return $this->priceFieldMetadata;
7aa78908 608 }
609
2408aa47
EM
610 /**
611 * Get the metadata for the given price field.
612 *
613 * Note this uses a different method to getPriceFieldMetadata.
614 *
615 * There is an assumption in the code currently that all purchases
616 * are within a single price set. However, discussions have been around
617 * the idea that when form-builder supports contributions price sets will
618 * not be used as form-builder in itself is a configuration unit.
619 *
620 * Currently there are couple of unit tests that mix & match & rather than
621 * updating the tests to avoid notices when orders are loaded for receipting,
622 * the migration to this new method is starting....
623 *
624 * @param int $id
625 *
626 * @return array
627 */
628 public function getPriceFieldMetadata(int $id): array {
629 return CRM_Price_BAO_PriceField::getPriceField($id);
630 }
631
812b2c0c
EM
632 /**
633 * Set the metadata for the order.
634 *
635 * @param array $metadata
636 */
637 protected function setPriceFieldMetadata($metadata) {
638 $this->priceFieldMetadata = $metadata;
639 if ($this->getForm()) {
640 CRM_Utils_Hook::buildAmount($this->form->getFormContext(), $this->form, $this->priceFieldMetadata);
641 }
642 }
643
be2e79c8 644 /**
645 * Get the metadata for the fields in the price set.
646 *
812b2c0c
EM
647 * @internal use in tested core code only.
648 *
be2e79c8 649 * @return array
650 */
651 public function getPriceSetMetadata(): array {
652 if (empty($this->priceSetMetadata)) {
653 $priceSetMetadata = CRM_Price_BAO_PriceSet::getCachedPriceSetDetail($this->getPriceSetID());
812b2c0c 654 $this->setPriceFieldMetadata($priceSetMetadata['fields']);
be2e79c8 655 unset($priceSetMetadata['fields']);
656 $this->priceSetMetadata = $priceSetMetadata;
657 }
658 return $this->priceSetMetadata;
659 }
660
661 /**
662 * Get the financial type id for the order.
663 *
812b2c0c
EM
664 * @internal use in tested core code only.
665 *
be2e79c8 666 * This may differ to the line items....
667 *
668 * @return int
669 */
670 public function getFinancialTypeID(): int {
671 return (int) $this->getOverrideFinancialTypeID() ?: $this->getPriceSetMetadata()['financial_type_id'];
672 }
673
7aa78908 674 /**
812b2c0c
EM
675 * Set the price field selection from an array of params containing price
676 * fields.
7aa78908 677 *
812b2c0c
EM
678 * This function takes the sort of 'anything & everything' parameters that
679 * come in from the form layer and filters them before assigning them to the
680 * priceSelection property.
7aa78908 681 *
682 * @param array $input
812b2c0c
EM
683 *
684 * @throws \API_Exception
7aa78908 685 */
de668aa8 686 public function setPriceSelectionFromUnfilteredInput(array $input): void {
7aa78908 687 foreach ($input as $fieldName => $value) {
688 if (strpos($fieldName, 'price_') === 0) {
689 $fieldID = substr($fieldName, 6);
690 if (is_numeric($fieldID)) {
691 $this->priceSelection[$fieldName] = $value;
692 }
693 }
694 }
812b2c0c
EM
695 if (empty($this->priceSelection) && isset($input['total_amount'])
696 && is_numeric($input['total_amount']) && !empty($input['financial_type_id'])) {
ca1b238b 697 $this->priceSelection['price_' . $this->getDefaultPriceFieldID()] = $input['total_amount'];
812b2c0c
EM
698 $this->setOverrideFinancialTypeID($input['financial_type_id']);
699 }
700 }
701
702 /**
703 * Get the id of the price field to use when just an amount is provided.
704 *
705 * @throws \API_Exception
ca1b238b
EM
706 *
707 * @return int
812b2c0c 708 */
ca1b238b
EM
709 public function getDefaultPriceFieldID():int {
710 if (!$this->defaultPriceField) {
711 $this->defaultPriceField = PriceField::get(FALSE)
712 ->addWhere('name', '=', 'contribution_amount')
713 ->addWhere('price_set_id.name', '=', 'default_contribution_amount')
714 ->execute()->first();
715 }
716 return $this->defaultPriceField['id'];
7aa78908 717 }
718
60e5cf34
EM
719 /**
720 * Get the id of the price field to use when just an amount is provided.
721 *
722 * @throws \API_Exception
723 *
724 * @return int
725 */
726 public function getDefaultPriceFieldValueID():int {
727 if (!$this->defaultPriceFieldValueID) {
728 $this->defaultPriceFieldValueID = PriceFieldValue::get(FALSE)
729 ->addWhere('name', '=', 'contribution_amount')
730 ->addWhere('price_field_id.name', '=', 'contribution_amount')
731 ->execute()->first()['id'];
732 }
733 return $this->defaultPriceFieldValueID;
734 }
735
7aa78908 736 /**
737 * Get line items.
738 *
739 * return array
740 *
741 * @throws \CiviCRM_API3_Exception
742 */
743 public function getLineItems():array {
744 if (empty($this->lineItems)) {
745 $this->lineItems = $this->calculateLineItems();
746 }
747 return $this->lineItems;
748 }
749
77274636
EM
750 /**
751 * Get line items in a 'traditional' indexing format.
752 *
753 * This ensures the line items are indexed by
754 * price field id - as required by the contribution BAO.
755 *
756 * @throws \CiviCRM_API3_Exception
757 */
758 public function getPriceFieldIndexedLineItems(): array {
759 $lines = [];
760 foreach ($this->getLineItems() as $item) {
761 $lines[$item['price_field_id']] = $item;
762 }
763 return $lines;
764 }
765
e84c5dc2 766 /**
767 * Get line items that specifically relate to memberships.
768 *
769 * return array
770 *
771 * @throws \CiviCRM_API3_Exception
772 */
773 public function getMembershipLineItems():array {
774 $lines = $this->getLineItems();
775 foreach ($lines as $index => $line) {
776 if (empty($line['membership_type_id'])) {
777 unset($lines[$index]);
778 continue;
779 }
780 if (empty($line['membership_num_terms'])) {
781 $lines[$index]['membership_num_terms'] = 1;
782 }
783 }
784 return $lines;
785 }
786
aba748e5 787 /**
788 * Get an array of all membership types included in the order.
789 *
790 * @return array
791 *
792 * @throws \CiviCRM_API3_Exception
793 */
794 public function getMembershipTypes(): array {
795 $types = [];
796 foreach ($this->getMembershipLineItems() as $line) {
797 $types[$line['membership_type_id']] = CRM_Member_BAO_MembershipType::getMembershipType((int) $line['membership_type_id']);
798 }
799 return $types;
800 }
801
802 /**
803 * Get an array of all membership types included in the order.
804 *
805 * @return array
806 *
807 * @throws \CiviCRM_API3_Exception
808 */
809 public function getRenewableMembershipTypes(): array {
810 $types = [];
811 foreach ($this->getMembershipTypes() as $id => $type) {
812 if (!empty($type['auto_renew'])) {
813 $types[$id] = $type;
814 }
815 }
816 return $types;
817 }
818
7aa78908 819 /**
820 * @return array
4df23f2a
EM
821 *
822 * @throws \API_Exception
7aa78908 823 */
824 protected function calculateLineItems(): array {
825 $lineItems = [];
826 $params = $this->getPriceSelection();
827 if ($this->getOverrideTotalAmount() !== FALSE) {
828 // We need to do this to keep getLine from doing weird stuff but the goal
829 // is to ditch getLine next round of refactoring
830 // and make the code more sane.
831 $params['total_amount'] = $this->getOverrideTotalAmount();
832 }
833
812b2c0c
EM
834 // Dummy value to prevent e-notice in getLine. We calculate tax in this class.
835 $params['financial_type_id'] = 0;
4df23f2a
EM
836 if ($this->getTemplateContributionID()) {
837 $lineItems = $this->getLinesFromTemplateContribution();
2408aa47
EM
838 // Set the price set ID from the first line item (we need to set this here
839 // to prevent a loop later when we retrieve the price field metadata to
840 // set the 'title' (as accessed from workflow message templates).
841 $this->setPriceSetID($lineItems[0]['price_field_id.price_set_id']);
4df23f2a
EM
842 }
843 else {
844 foreach ($this->getPriceOptions() as $fieldID => $valueID) {
cb74e389
EM
845 if ($valueID !== '') {
846 $this->setPriceSetIDFromSelectedField($fieldID);
847 $throwAwayArray = [];
848 // @todo - still using getLine for now but better to bring it to this class & do a better job.
849 $newLines = CRM_Price_BAO_PriceSet::getLine($params, $throwAwayArray, $this->getPriceSetID(), $this->getPriceFieldSpec($fieldID), $fieldID)[1];
850 foreach ($newLines as $newLine) {
851 $lineItems[$newLine['price_field_value_id']] = $newLine;
852 }
4df23f2a 853 }
58315149 854 }
7aa78908 855 }
4df23f2a
EM
856 // Set the line item count here because it is needed to determine whether
857 // we can use overrides and would not be set yet if we have loaded them from
858 // a template contribution.
859 $this->setLineItemCount(count($lineItems));
7aa78908 860
7aa78908 861 foreach ($lineItems as &$lineItem) {
4df23f2a
EM
862 // Set the price set id if not set above. Note that the above
863 // requires it for line retrieval but we want to fix that as it
864 // should not be required at that point.
865 $this->setPriceSetIDFromSelectedField($lineItem['price_field_id']);
7aa78908 866 // Set any pre-calculation to zero as we will calculate.
867 $lineItem['tax_amount'] = 0;
4df23f2a 868 if ($this->isOverrideLineItemFinancialType($lineItem['financial_type_id']) !== FALSE) {
7aa78908 869 $lineItem['financial_type_id'] = $this->getOverrideFinancialTypeID();
870 }
0a859bb8 871 $lineItem['tax_rate'] = $taxRate = $this->getTaxRate((int) $lineItem['financial_type_id']);
7aa78908 872 if ($this->getOverrideTotalAmount() !== FALSE) {
77274636 873 $this->addTotalsToLineBasedOnOverrideTotal((int) $lineItem['financial_type_id'], $lineItem);
7aa78908 874 }
875 elseif ($taxRate) {
876 $lineItem['tax_amount'] = ($taxRate / 100) * $lineItem['line_total'];
877 }
2408aa47 878 $lineItem['title'] = $this->getLineItemTitle($lineItem);
7aa78908 879 }
880 return $lineItems;
881 }
882
883 /**
58315149 884 * Get the total amount for the order.
7aa78908 885 *
886 * @return float
887 *
888 * @throws \CiviCRM_API3_Exception
889 */
890 public function getTotalTaxAmount() :float {
891 $amount = 0.0;
892 foreach ($this->getLineItems() as $lineItem) {
893 $amount += $lineItem['tax_amount'] ?? 0.0;
894 }
895 return $amount;
896 }
897
58315149 898 /**
765d02a3 899 * Get the total amount for the order.
58315149 900 *
901 * @return float
902 *
903 * @throws \CiviCRM_API3_Exception
904 */
905 public function getTotalAmount() :float {
906 $amount = 0.0;
907 foreach ($this->getLineItems() as $lineItem) {
53c8b1be 908 $amount += ($lineItem['line_total'] ?? 0.0) + ($lineItem['tax_amount'] ?? 0.0);
58315149 909 }
910 return $amount;
911 }
912
765d02a3
EM
913 /**
914 * Get the total amount relating to memberships for the order.
915 *
916 * @return float
917 *
918 * @throws \CiviCRM_API3_Exception
919 */
920 public function getMembershipTotalAmount() :float {
921 $amount = 0.0;
922 foreach ($this->getMembershipLineItems() as $lineItem) {
923 $amount += ($lineItem['line_total'] ?? 0.0) + ($lineItem['tax_amount'] ?? 0.0);
924 }
925 return $amount;
926 }
927
dd118b15 928 /**
929 * Get the tax rate for the given financial type.
930 *
931 * @param int $financialTypeID
932 *
933 * @return float
934 */
935 public function getTaxRate(int $financialTypeID) {
936 $taxRates = CRM_Core_PseudoConstant::getTaxRates();
937 if (!isset($taxRates[$financialTypeID])) {
938 return 0;
939 }
940 return $taxRates[$financialTypeID];
941 }
942
812b2c0c
EM
943 /**
944 * @param $fieldID
945 *
946 * @throws \API_Exception
947 */
948 protected function setPriceSetIDFromSelectedField($fieldID): void {
949 if (!isset($this->priceSetID)) {
950 $this->setPriceSetID(PriceField::get(FALSE)
951 ->addSelect('price_set_id')
952 ->addWhere('id', '=', $fieldID)
953 ->execute()
954 ->first()['price_set_id']);
955 }
956 }
957
ca44bb7e
EM
958 /**
959 * Set the line item.
960 *
961 * This function augments the line item where possible. The calling code
962 * should not attempt to set taxes. This function allows minimal values
963 * to be passed for the default price sets - ie if only membership_type_id is
964 * specified the price_field_id and price_value_id will be determined.
965 *
966 * @param array $lineItem
967 * @param int|string $index
968 *
969 * @throws \API_Exception
970 * @internal tested core code usage only.
971 * @internal use in tested core code only.
972 *
973 */
974 public function setLineItem(array $lineItem, $index): void {
77274636
EM
975 if (!isset($this->priceSetID)) {
976 if (!empty($lineItem['price_field_id'])) {
977 $this->setPriceSetIDFromSelectedField($lineItem['price_field_id']);
978 }
979 else {
980 // we are using either the default membership or default contribution
981 // If membership type is passed in we use the default price field.
982 $component = !empty($lineItem['membership_type_id']) ? 'membership' : 'contribution';
983 $this->setPriceSetToDefault($component);
984 }
ca44bb7e
EM
985 }
986 if (!isset($lineItem['financial_type_id'])) {
987 $lineItem['financial_type_id'] = $this->getDefaultFinancialTypeID();
988 }
989 if (!is_numeric($lineItem['financial_type_id'])) {
990 $lineItem['financial_type_id'] = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'financial_type_id', $lineItem['financial_type_id']);
991 }
77274636
EM
992 if ($this->getOverrideTotalAmount()) {
993 $this->addTotalsToLineBasedOnOverrideTotal((int) $lineItem['financial_type_id'], $lineItem);
994 }
995 else {
8193ed74
EM
996 $lineItem['tax_rate'] = $this->getTaxRate($lineItem['financial_type_id']);
997 $lineItem['tax_amount'] = ($lineItem['tax_rate'] / 100) * $lineItem['line_total'];
77274636 998 }
ca44bb7e
EM
999 if (!empty($lineItem['membership_type_id'])) {
1000 $lineItem['entity_table'] = 'civicrm_membership';
1001 if (empty($lineItem['price_field_id']) && empty($lineItem['price_field_value_id'])) {
ca44bb7e
EM
1002 $lineItem = $this->fillMembershipLine($lineItem);
1003 }
1004 }
ca1b238b
EM
1005 if ($this->getPriceSetID() === $this->getDefaultPriceSetForComponent('contribution')) {
1006 $this->fillDefaultContributionLine($lineItem);
1007 }
8193ed74
EM
1008 if (empty($lineItem['label'])) {
1009 $lineItem['label'] = PriceFieldValue::get(FALSE)->addWhere('id', '=', (int) $lineItem['price_field_value_id'])->addSelect('label')->execute()->first()['label'];
1010 }
1011 if (empty($lineItem['price_field_id']) && !empty($lineItem['membership_type_id'])) {
1012 // We have to 'guess' the price field since the calling code hasn't
1013 // passed it in (which it really should but ... history).
1014 foreach ($this->priceFieldMetadata as $pricefield) {
1015 foreach ($pricefield['options'] ?? [] as $option) {
1016 if ((int) $option['membership_type_id'] === $lineItem['membership_type_id']) {
1017 $lineItem['price_field_id'] = $pricefield['id'];
1018 $lineItem['price_field_value_id'] = $option['id'];
1019 }
1020 }
1021 }
1022 }
1023 if (empty($lineItem['title'])) {
2408aa47 1024 $lineItem['title'] = $this->getLineItemTitle($lineItem);
8193ed74 1025 }
ca44bb7e
EM
1026 $this->lineItems[$index] = $lineItem;
1027 }
1028
1029 /**
1030 * Set a value on a line item.
1031 *
1032 * @internal only use in core tested code.
1033 *
1034 * @param string $name
1035 * @param mixed $value
1036 * @param string|int $index
1037 */
1038 public function setLineItemValue(string $name, $value, $index): void {
1039 $this->lineItems[$index][$name] = $value;
1040 }
1041
1042 /**
1043 * @param int|string $index
1044 *
1045 * @return string
1046 */
1047 public function getLineItemEntity($index):string {
1048 // @todo - ensure entity_table is set in setLineItem, go back to enotices here.
1049 return str_replace('civicrm_', '', ($this->lineItems[$index]['entity_table'] ?? 'contribution'));
1050 }
1051
1052 /**
1053 * Get the ordered line item.
1054 *
1055 * @param string|int $index
1056 *
1057 * @return array
1058 */
1059 public function getLineItem($index): array {
1060 return $this->lineItems[$index];
1061 }
1062
1063 /**
1064 * Fills in additional data for the membership line.
1065 *
1066 * The minimum requirement is the membership_type_id and that priceSetID is set.
1067 *
1068 * @param array $lineItem
1069 *
1070 * @return array
1071 */
1072 protected function fillMembershipLine(array $lineItem): array {
1073 $fields = $this->getPriceFieldsMetadata();
77274636
EM
1074 foreach ($fields as $field) {
1075 if (!isset($lineItem['price_field_value_id'])) {
1076 foreach ($field['options'] as $option) {
1077 if ((int) $option['membership_type_id'] === (int) $lineItem['membership_type_id']) {
1078 $lineItem['price_field_id'] = $field['id'];
8193ed74 1079 $lineItem['price_field_id.label'] = $field['label'];
77274636
EM
1080 $lineItem['price_field_value_id'] = $option['id'];
1081 $lineItem['qty'] = 1;
1082 }
ca44bb7e
EM
1083 }
1084 }
77274636
EM
1085 if (isset($lineItem['price_field_value_id'], $field['options'][$lineItem['price_field_value_id']])) {
1086 $option = $field['options'][$lineItem['price_field_value_id']];
1087 }
ca44bb7e 1088 }
ca44bb7e
EM
1089 $lineItem['unit_price'] = $lineItem['line_total'] ?? $option['amount'];
1090 $lineItem['label'] = $lineItem['label'] ?? $option['label'];
1091 $lineItem['field_title'] = $lineItem['field_title'] ?? $option['label'];
1092 $lineItem['financial_type_id'] = $lineItem['financial_type_id'] ?: ($this->getDefaultFinancialTypeID() ?? $option['financial_type_id']);
1093 return $lineItem;
1094 }
1095
77274636
EM
1096 /**
1097 * Add total_amount and tax_amount to the line from the override total.
1098 *
1099 * @param int $financialTypeID
1100 * @param array $lineItem
1101 *
1102 * @return void
1103 */
1104 protected function addTotalsToLineBasedOnOverrideTotal(int $financialTypeID, array &$lineItem): void {
0a859bb8 1105 $lineItem['tax_rate'] = $taxRate = $this->getTaxRate($financialTypeID);
77274636
EM
1106 if ($taxRate) {
1107 // Total is tax inclusive.
1108 $lineItem['tax_amount'] = ($taxRate / 100) * $this->getOverrideTotalAmount() / (1 + ($taxRate / 100));
e66cf74b 1109 $lineItem['line_total'] = $this->getOverrideTotalAmount() - $lineItem['tax_amount'];
77274636
EM
1110 }
1111 else {
e66cf74b
DRJ
1112 $lineItem['line_total'] = $this->getOverrideTotalAmount();
1113 }
1114 if (!empty($lineItem['qty'])) {
1115 $lineItem['unit_price'] = $lineItem['line_total'] / $lineItem['qty'];
1116 }
1117 else {
1118 $lineItem['unit_price'] = $lineItem['line_total'];
77274636
EM
1119 }
1120 }
1121
4df23f2a
EM
1122 /**
1123 * Get the line items from a template.
1124 *
1125 * @return \Civi\Api4\Generic\Result
1126 *
1127 * @throws \API_Exception
1128 */
1129 protected function getLinesFromTemplateContribution(): array {
1130 $lines = $this->getLinesForContribution();
1131 foreach ($lines as &$line) {
1132 // The apiv4 insists on adding id - so let it get all the details
1133 // and we will filter out those that are not part of a template here.
1134 unset($line['id'], $line['contribution_id']);
1135 }
1136 return $lines;
1137 }
1138
502822e7
EM
1139 /**
1140 * Get the constructed line items formatted for the v3 Order api.
1141 *
1142 * @return array
1143 *
1144 * @internal core tested code only.
1145 *
1146 * @throws \CiviCRM_API3_Exception
1147 */
1148 public function getLineItemForV3OrderApi(): array {
1149 $lineItems = [];
1150 foreach ($this->getLineItems() as $key => $line) {
1151 $lineItems[] = [
1152 'line_item' => [$line['price_field_value_id'] => $line],
1153 'params' => $this->entityParameters[$key] ?? [],
1154 ];
1155 }
1156 return $lineItems;
1157 }
1158
4df23f2a
EM
1159 /**
1160 * @return array
1161 * @throws \API_Exception
1162 * @throws \Civi\API\Exception\UnauthorizedException
1163 */
1164 protected function getLinesForContribution(): array {
1165 return (array) LineItem::get(FALSE)
1166 ->addWhere('contribution_id', '=', $this->getTemplateContributionID())
1167 ->setSelect([
1168 'contribution_id',
1169 'entity_id',
1170 'entity_table',
1171 'price_field_id',
8193ed74 1172 'price_field_id.label',
691345e9 1173 'price_field_id.price_set_id',
4df23f2a
EM
1174 'price_field_value_id',
1175 'financial_type_id',
1176 'label',
1177 'qty',
1178 'unit_price',
1179 'line_total',
1180 'tax_amount',
1181 'non_deductible_amount',
1182 'participant_count',
1183 'membership_num_terms',
1184 ])
1185 ->execute();
1186 }
1187
ca1b238b
EM
1188 /**
1189 * Get the default price set id for the given component.
1190 *
1191 * @param string $component
1192 *
1193 * @return int
1194 * @throws \API_Exception
1195 */
1196 protected function getDefaultPriceSetForComponent(string $component): int {
1197 if (!isset($this->defaultPriceSets[$component])) {
1198 $this->defaultPriceSets[$component] = PriceSet::get(FALSE)
1199 ->addWhere('name', '=', ($component === 'membership' ? 'default_membership_type_amount' : 'default_contribution_amount'))
1200 ->execute()
1201 ->first()['id'];
1202 }
1203 return $this->defaultPriceSets[$component];
1204 }
1205
1206 /**
1207 * Fill in values for a default contribution line item.
1208 *
1209 * @param array $lineItem
1210 *
1211 * @throws \API_Exception
1212 */
1213 protected function fillDefaultContributionLine(array &$lineItem): void {
1214 $defaults = [
1215 'qty' => 1,
1216 'price_field_id' => $this->getDefaultPriceFieldID(),
8193ed74 1217 'price_field_id.label' => $this->defaultPriceField['label'],
60e5cf34 1218 'price_field_value_id' => $this->getDefaultPriceFieldValueID(),
ca1b238b
EM
1219 'entity_table' => 'civicrm_contribution',
1220 'unit_price' => $lineItem['line_total'],
1221 'label' => ts('Contribution Amount'),
1222 ];
1223 $lineItem = array_merge($defaults, $lineItem);
1224 }
1225
2408aa47
EM
1226 /**
1227 * Get a 'title' for the line item.
1228 *
1229 * This descriptor is used in message templates. It could conceivably
1230 * by used elsewhere but if so determination would likely move to the api.
1231 *
1232 * @param array $lineItem
1233 *
1234 * @return string
1235 */
1236 private function getLineItemTitle(array $lineItem): string {
1237 // Title is used in output for workflow templates.
1238 $htmlType = $this->getPriceFieldSpec($lineItem['price_field_id'])['html_type'] ?? NULL;
1239 $lineItemTitle = (!$htmlType || $htmlType === 'Text') ? $lineItem['label'] : $this->getPriceFieldSpec($lineItem['price_field_id'])['label'] . ' - ' . $lineItem['label'];
1240 if (!empty($lineItem['price_field_value_id'])) {
1241 $description = $this->priceFieldValueMetadata[$lineItem['price_field_value_id']]['description'] ?? '';
1242 if ($description) {
1243 $lineItemTitle .= ' ' . CRM_Utils_String::ellipsify($description, 30);
1244 }
1245 }
1246 return $lineItemTitle;
1247 }
1248
7aa78908 1249}