[REF] dev/core#2790 move preProcess static to the trait
[civicrm-core.git] / CRM / Contribute / Form / Task / PDFLetter.php
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
12 /**
13 *
14 * @package CRM
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
16 */
17
18 /**
19 * This class provides the functionality to create PDF letter for a group of contacts or a single contact.
20 */
21 class CRM_Contribute_Form_Task_PDFLetter extends CRM_Contribute_Form_Task {
22
23 use CRM_Contact_Form_Task_PDFTrait;
24
25 /**
26 * All the existing templates in the system.
27 *
28 * @var array
29 */
30 public $_templates = NULL;
31
32 public $_single = NULL;
33
34 public $_cid = NULL;
35
36 /**
37 * Build all the data structures needed to build the form.
38 */
39 public function preProcess() {
40 $this->skipOnHold = $this->skipDeceased = FALSE;
41 $this->preProcessPDF();
42 parent::preProcess();
43 $this->assign('single', $this->isSingle());
44 }
45
46 /**
47 * This virtual function is used to set the default values of
48 * various form elements
49 *
50 * access public
51 *
52 * @return array
53 * reference to the array of default values
54 */
55
56 /**
57 * @return array
58 */
59 public function setDefaultValues() {
60 $defaults = $this->getPDFDefaultValues();
61 if (isset($this->_activityId)) {
62 $params = ['id' => $this->_activityId];
63 CRM_Activity_BAO_Activity::retrieve($params, $defaults);
64 $defaults['html_message'] = $defaults['details'] ?? NULL;
65 }
66 else {
67 $defaults['thankyou_update'] = 1;
68 }
69 return $defaults;
70 }
71
72 /**
73 * Build the form object.
74 *
75 * @throws \CRM_Core_Exception
76 */
77 public function buildQuickForm() {
78 //enable form element
79 $this->assign('suppressForm', FALSE);
80
81 // Contribute PDF tasks allow you to email as well, so we need to add email address to those forms
82 $this->add('select', 'from_email_address', ts('From Email Address'), $this->_fromEmails, TRUE);
83 $this->addPDFElementsToForm();
84
85 // specific need for contributions
86 $this->add('static', 'more_options_header', NULL, ts('Thank-you Letter Options'));
87 $this->add('checkbox', 'receipt_update', ts('Update receipt dates for these contributions'), FALSE);
88 $this->add('checkbox', 'thankyou_update', ts('Update thank-you dates for these contributions'), FALSE);
89
90 // Group options for tokens are not yet implemented. dgg
91 $options = [
92 '' => ts('- no grouping -'),
93 'contact_id' => ts('Contact'),
94 'contribution_recur_id' => ts('Contact and Recurring'),
95 'financial_type_id' => ts('Contact and Financial Type'),
96 'campaign_id' => ts('Contact and Campaign'),
97 'payment_instrument_id' => ts('Contact and Payment Method'),
98 ];
99 $this->addElement('select', 'group_by', ts('Group contributions by'), $options, [], "<br/>", FALSE);
100 // this was going to be free-text but I opted for radio options in case there was a script injection risk
101 $separatorOptions = ['comma' => 'Comma', 'td' => 'Horizontal Table Cell', 'tr' => 'Vertical Table Cell', 'br' => 'Line Break'];
102
103 $this->addElement('select', 'group_by_separator', ts('Separator (grouped contributions)'), $separatorOptions);
104 $emailOptions = [
105 '' => ts('Generate PDFs for printing (only)'),
106 'email' => ts('Send emails where possible. Generate printable PDFs for contacts who cannot receive email.'),
107 'both' => ts('Send emails where possible. Generate printable PDFs for all contacts.'),
108 ];
109 if (CRM_Core_Config::singleton()->doNotAttachPDFReceipt) {
110 $emailOptions['pdfemail'] = ts('Send emails with an attached PDF where possible. Generate printable PDFs for contacts who cannot receive email.');
111 $emailOptions['pdfemail_both'] = ts('Send emails with an attached PDF where possible. Generate printable PDFs for all contacts.');
112 }
113 $this->addElement('select', 'email_options', ts('Print and email options'), $emailOptions, [], "<br/>", FALSE);
114
115 $this->addButtons([
116 [
117 'type' => 'upload',
118 'name' => ts('Make Thank-you Letters'),
119 'isDefault' => TRUE,
120 ],
121 [
122 'type' => 'cancel',
123 'name' => ts('Done'),
124 ],
125 ]);
126
127 }
128
129 /**
130 * Process the form after the input has been submitted and validated.
131 *
132 * @throws \CRM_Core_Exception
133 */
134 public function postProcess() {
135 $formValues = $this->controller->exportValues($this->getName());
136 [$formValues, $categories, $html_message, $messageToken, $returnProperties] = CRM_Contact_Form_Task_PDFLetterCommon::processMessageTemplate($formValues);
137 $isPDF = FALSE;
138 $emailParams = [];
139 if (!empty($formValues['email_options'])) {
140 $returnProperties['email'] = $returnProperties['on_hold'] = $returnProperties['is_deceased'] = $returnProperties['do_not_email'] = 1;
141 $emailParams = [
142 'subject' => $formValues['subject'] ?? NULL,
143 'from' => $formValues['from_email_address'] ?? NULL,
144 ];
145
146 $emailParams['from'] = CRM_Utils_Mail::formatFromAddress($emailParams['from']);
147
148 // We need display_name for emailLetter() so add to returnProperties here
149 $returnProperties['display_name'] = 1;
150 if (stristr($formValues['email_options'], 'pdfemail')) {
151 $isPDF = TRUE;
152 }
153 }
154 // update dates ?
155 $receipt_update = $formValues['receipt_update'] ?? FALSE;
156 $thankyou_update = $formValues['thankyou_update'] ?? FALSE;
157 $nowDate = date('YmdHis');
158 $receipts = $thanks = $emailed = 0;
159 $updateStatus = '';
160 $task = 'CRM_Contribution_Form_Task_PDFLetterCommon';
161 $realSeparator = ', ';
162 $tableSeparators = [
163 'td' => '</td><td>',
164 'tr' => '</td></tr><tr><td>',
165 ];
166 //the original thinking was mutliple options - but we are going with only 2 (comma & td) for now in case
167 // there are security (& UI) issues we need to think through
168 if (isset($formValues['group_by_separator'])) {
169 if (in_array($formValues['group_by_separator'], ['td', 'tr'])) {
170 $realSeparator = $tableSeparators[$formValues['group_by_separator']];
171 }
172 elseif ($formValues['group_by_separator'] == 'br') {
173 $realSeparator = "<br />";
174 }
175 }
176 // a placeholder in case the separator is common in the string - e.g ', '
177 $separator = '****~~~~';
178 $groupBy = $this->getSubmittedValue('group_by');
179
180 // skip some contacts ?
181 $skipOnHold = $this->skipOnHold ?? FALSE;
182 $skipDeceased = $this->skipDeceased ?? TRUE;
183 $contributionIDs = $this->getIDs();
184 if ($this->isQueryIncludesSoftCredits()) {
185 $contributionIDs = [];
186 $result = $this->getSearchQueryResults();
187 while ($result->fetch()) {
188 $this->_contactIds[$result->contact_id] = $result->contact_id;
189 $contributionIDs["{$result->contact_id}-{$result->contribution_id}"] = $result->contribution_id;
190 }
191 }
192 [$contributions, $contacts] = $this->buildContributionArray($groupBy, $contributionIDs, $returnProperties, $skipOnHold, $skipDeceased, $messageToken, $task, $separator, $this->isQueryIncludesSoftCredits());
193 $html = [];
194 $contactHtml = $emailedHtml = [];
195 foreach ($contributions as $contributionId => $contribution) {
196 $contact = &$contacts[$contribution['contact_id']];
197 $grouped = FALSE;
198 $groupByID = 0;
199 if ($groupBy) {
200 $groupByID = empty($contribution[$groupBy]) ? 0 : $contribution[$groupBy];
201 $contribution = $contact['combined'][$groupBy][$groupByID];
202 $grouped = TRUE;
203 }
204
205 if (empty($groupBy) || empty($contact['is_sent'][$groupBy][$groupByID])) {
206 $html[$contributionId] = $this->generateHtml($contact, $contribution, $groupBy, $contributions, $realSeparator, $tableSeparators, $messageToken, $html_message, $separator, $grouped, $groupByID);
207 $contactHtml[$contact['contact_id']][] = $html[$contributionId];
208 if (!empty($formValues['email_options'])) {
209 if ($this->emailLetter($contact, $html[$contributionId], $isPDF, $formValues, $emailParams)) {
210 $emailed++;
211 if (!stristr($formValues['email_options'], 'both')) {
212 $emailedHtml[$contributionId] = TRUE;
213 }
214 }
215 }
216 $contact['is_sent'][$groupBy][$groupByID] = TRUE;
217 }
218 // Update receipt/thankyou dates
219 $contributionParams = ['id' => $contributionId];
220 if ($receipt_update) {
221 $contributionParams['receipt_date'] = $nowDate;
222 }
223 if ($thankyou_update) {
224 $contributionParams['thankyou_date'] = $nowDate;
225 }
226 if ($receipt_update || $thankyou_update) {
227 civicrm_api3('Contribution', 'create', $contributionParams);
228 $receipts = ($receipt_update ? $receipts + 1 : $receipts);
229 $thanks = ($thankyou_update ? $thanks + 1 : $thanks);
230 }
231 }
232
233 $contactIds = array_keys($contacts);
234 CRM_Contact_Form_Task_PDFLetterCommon::createActivities($this, $html_message, $contactIds, CRM_Utils_Array::value('subject', $formValues, ts('Thank you letter')), CRM_Utils_Array::value('campaign_id', $formValues), $contactHtml);
235 $html = array_diff_key($html, $emailedHtml);
236
237 if (!empty($formValues['is_unit_test'])) {
238 return $html;
239 }
240
241 //CRM-19761
242 if (!empty($html)) {
243 // Set the filename for the PDF using the Activity Subject, if defined. Remove unwanted characters and limit the length to 200 characters.
244 if (!empty($formValues['subject'])) {
245 $fileName = CRM_Utils_File::makeFilenameWithUnicode($formValues['subject'], '_', 200);
246 }
247 else {
248 $fileName = 'CiviLetter';
249 }
250
251 if ($this->getSubmittedValue('document_type') === 'pdf') {
252 CRM_Utils_PDF_Utils::html2pdf($html, $fileName . '.pdf', FALSE, $formValues);
253 }
254 else {
255 CRM_Utils_PDF_Document::html2doc($html, $fileName . '.' . $this->getSubmittedValue('document_type'), $formValues);
256 }
257 }
258
259 $this->postProcessHook();
260
261 if ($emailed) {
262 $updateStatus = ts('Receipts have been emailed to %1 contributions.', [1 => $emailed]);
263 }
264 if ($receipts) {
265 $updateStatus = ts('Receipt date has been updated for %1 contributions.', [1 => $receipts]);
266 }
267 if ($thanks) {
268 $updateStatus .= ' ' . ts('Thank-you date has been updated for %1 contributions.', [1 => $thanks]);
269 }
270
271 if ($updateStatus) {
272 CRM_Core_Session::setStatus($updateStatus);
273 }
274 if (!empty($html)) {
275 // ie. we have only sent emails - lets no show a white screen
276 CRM_Utils_System::civiExit();
277 }
278 }
279
280 /**
281 * List available tokens for this form.
282 *
283 * @return array
284 */
285 public function listTokens() {
286 $tokens = CRM_Core_SelectValues::contactTokens();
287 $tokens = array_merge(CRM_Core_SelectValues::contributionTokens(), $tokens);
288 $tokens = array_merge(CRM_Core_SelectValues::domainTokens(), $tokens);
289 return $tokens;
290 }
291
292 /**
293 * Generate the contribution array from the form, we fill in the contact details and determine any aggregation
294 * around contact_id of contribution_recur_id
295 *
296 * @param string $groupBy
297 * @param array $contributionIDs
298 * @param array $returnProperties
299 * @param bool $skipOnHold
300 * @param bool $skipDeceased
301 * @param array $messageToken
302 * @param string $task
303 * @param string $separator
304 * @param bool $isIncludeSoftCredits
305 *
306 * @return array
307 */
308 public function buildContributionArray($groupBy, $contributionIDs, $returnProperties, $skipOnHold, $skipDeceased, $messageToken, $task, $separator, $isIncludeSoftCredits) {
309 $contributions = $contacts = [];
310 foreach ($contributionIDs as $item => $contributionId) {
311 $contribution = CRM_Contribute_BAO_Contribution::getContributionTokenValues($contributionId, $messageToken)['values'][$contributionId];
312 $contribution['campaign'] = $contribution['contribution_campaign_title'] ?? NULL;
313 $contributions[$contributionId] = $contribution;
314
315 if ($isIncludeSoftCredits) {
316 //@todo find out why this happens & add comments
317 [$contactID] = explode('-', $item);
318 $contactID = (int) $contactID;
319 }
320 else {
321 $contactID = $contribution['contact_id'];
322 }
323 if (!isset($contacts[$contactID])) {
324 $contacts[$contactID] = [];
325 $contacts[$contactID]['contact_aggregate'] = 0;
326 $contacts[$contactID]['combined'] = $contacts[$contactID]['contribution_ids'] = [];
327 }
328
329 $contacts[$contactID]['contact_aggregate'] += $contribution['total_amount'];
330 $groupByID = empty($contribution[$groupBy]) ? 0 : $contribution[$groupBy];
331
332 $contacts[$contactID]['contribution_ids'][$groupBy][$groupByID][$contributionId] = TRUE;
333 if (!isset($contacts[$contactID]['combined'][$groupBy]) || !isset($contacts[$contactID]['combined'][$groupBy][$groupByID])) {
334 $contacts[$contactID]['combined'][$groupBy][$groupByID] = $contribution;
335 $contacts[$contactID]['aggregates'][$groupBy][$groupByID] = $contribution['total_amount'];
336 }
337 else {
338 $contacts[$contactID]['combined'][$groupBy][$groupByID] = self::combineContributions($contacts[$contactID]['combined'][$groupBy][$groupByID], $contribution, $separator);
339 $contacts[$contactID]['aggregates'][$groupBy][$groupByID] += $contribution['total_amount'];
340 }
341 }
342 // Assign the available contributions before calling tokens so hooks parsing smarty can access it.
343 // Note that in core code you can only use smarty here if enable if for the whole site, incl
344 // CiviMail, with a big performance impact.
345 // Hooks allow more nuanced smarty usage here.
346 CRM_Core_Smarty::singleton()->assign('contributions', $contributions);
347 foreach ($contacts as $contactID => $contact) {
348 [$tokenResolvedContacts] = CRM_Utils_Token::getTokenDetails(['contact_id' => $contactID],
349 $returnProperties,
350 $skipOnHold,
351 $skipDeceased,
352 NULL,
353 $messageToken,
354 $task
355 );
356 $contacts[$contactID] = array_merge($tokenResolvedContacts[$contactID], $contact);
357 }
358 return [$contributions, $contacts];
359 }
360
361 /**
362 * We combine the contributions by adding the contribution to each field with the separator in
363 * between the existing value and the new one. We put the separator there even if empty so it is clear what the
364 * value for previous contributions was
365 *
366 * @param array $existing
367 * @param array $contribution
368 * @param string $separator
369 *
370 * @return array
371 */
372 public static function combineContributions($existing, $contribution, $separator) {
373 foreach ($contribution as $field => $value) {
374 $existing[$field] = isset($existing[$field]) ? $existing[$field] . $separator : '';
375 $existing[$field] .= $value;
376 }
377 return $existing;
378 }
379
380 /**
381 * We are going to retrieve the combined contribution and if smarty mail is enabled we
382 * will also assign an array of contributions for this contact to the smarty template
383 *
384 * @param array $contact
385 * @param array $contributions
386 * @param $groupBy
387 * @param int $groupByID
388 */
389 public static function assignCombinedContributionValues($contact, $contributions, $groupBy, $groupByID) {
390 CRM_Core_Smarty::singleton()->assign('contact_aggregate', $contact['contact_aggregate']);
391 CRM_Core_Smarty::singleton()
392 ->assign('contributions', $contributions);
393 CRM_Core_Smarty::singleton()->assign('contribution_aggregate', $contact['aggregates'][$groupBy][$groupByID]);
394 }
395
396 /**
397 * @param $contact
398 * @param $formValues
399 * @param $contribution
400 * @param $groupBy
401 * @param $contributions
402 * @param $realSeparator
403 * @param $tableSeparators
404 * @param $messageToken
405 * @param $html_message
406 * @param $separator
407 * @param $categories
408 * @param bool $grouped
409 * @param int $groupByID
410 *
411 * @return string
412 * @throws \CRM_Core_Exception
413 */
414 public function generateHtml(&$contact, $contribution, $groupBy, $contributions, $realSeparator, $tableSeparators, $messageToken, $html_message, $separator, $grouped, $groupByID) {
415 static $validated = FALSE;
416 $html = NULL;
417
418 $groupedContributions = array_intersect_key($contributions, $contact['contribution_ids'][$groupBy][$groupByID]);
419 CRM_Contribute_Form_Task_PDFLetter::assignCombinedContributionValues($contact, $groupedContributions, $groupBy, $groupByID);
420
421 if (empty($groupBy) || empty($contact['is_sent'][$groupBy][$groupByID])) {
422 if (!$validated && in_array($realSeparator, $tableSeparators) && !CRM_Contribute_Form_Task_PDFLetter::isValidHTMLWithTableSeparator($messageToken, $html_message)) {
423 $realSeparator = ', ';
424 CRM_Core_Session::setStatus(ts('You have selected the table cell separator, but one or more token fields are not placed inside a table cell. This would result in invalid HTML, so comma separators have been used instead.'));
425 }
426 $validated = TRUE;
427 $html = str_replace($separator, $realSeparator, $this->resolveTokens($html_message, $contact, $contribution, $messageToken, $grouped, $separator, $groupedContributions));
428 }
429
430 return $html;
431 }
432
433 /**
434 * Send pdf by email.
435 *
436 * @param array $contact
437 * @param string $html
438 *
439 * @param $is_pdf
440 * @param array $format
441 * @param array $params
442 *
443 * @return bool
444 */
445 public function emailLetter($contact, $html, $is_pdf, $format = [], $params = []) {
446 try {
447 if (empty($contact['email'])) {
448 return FALSE;
449 }
450 $mustBeEmpty = ['do_not_email', 'is_deceased', 'on_hold'];
451 foreach ($mustBeEmpty as $emptyField) {
452 if (!empty($contact[$emptyField])) {
453 return FALSE;
454 }
455 }
456
457 $defaults = [
458 'toName' => $contact['display_name'],
459 'toEmail' => $contact['email'],
460 'text' => '',
461 'html' => $html,
462 ];
463 if (empty($params['from'])) {
464 $emails = CRM_Core_BAO_Email::getFromEmail();
465 $emails = array_keys($emails);
466 $defaults['from'] = array_pop($emails);
467 }
468 else {
469 $defaults['from'] = $params['from'];
470 }
471 if (!empty($params['subject'])) {
472 $defaults['subject'] = $params['subject'];
473 }
474 else {
475 $defaults['subject'] = ts('Thank you for your contribution/s');
476 }
477 if ($is_pdf) {
478 $defaults['html'] = ts('Please see attached');
479 $defaults['attachments'] = [CRM_Utils_Mail::appendPDF('ThankYou.pdf', $html, $format)];
480 }
481 $params = array_merge($defaults);
482 return CRM_Utils_Mail::send($params);
483 }
484 catch (CRM_Core_Exception $e) {
485 return FALSE;
486 }
487 }
488
489 /**
490 * Check that the token only appears in a table cell. The '</td><td>' separator cannot otherwise work
491 * Calculate the number of times it appears IN the cell & the number of times it appears - should be the same!
492 *
493 * @param string $token
494 * @param string $entity
495 * @param string $textToSearch
496 *
497 * @return bool
498 */
499 public static function isHtmlTokenInTableCell($token, $entity, $textToSearch) {
500 $tokenToMatch = $entity . '\.' . $token;
501 $pattern = '|<td(?![\w-])((?!</td>).)*\{' . $tokenToMatch . '\}.*?</td>|si';
502 $within = preg_match_all($pattern, $textToSearch);
503 $total = preg_match_all("|{" . $tokenToMatch . "}|", $textToSearch);
504 return ($within == $total);
505 }
506
507 /**
508 * Check whether any of the tokens exist in the html outside a table cell.
509 * If they do the table cell separator is not supported (return false)
510 * At this stage we are only anticipating contributions passed in this way but
511 * it would be easy to add others
512 * @param $tokens
513 * @param $html
514 *
515 * @return bool
516 */
517 public static function isValidHTMLWithTableSeparator($tokens, $html) {
518 $relevantEntities = ['contribution'];
519 foreach ($relevantEntities as $entity) {
520 if (isset($tokens[$entity]) && is_array($tokens[$entity])) {
521 foreach ($tokens[$entity] as $token) {
522 if (!CRM_Contribute_Form_Task_PDFLetter::isHtmlTokenInTableCell($token, $entity, $html)) {
523 return FALSE;
524 }
525 }
526 }
527 }
528 return TRUE;
529 }
530
531 /**
532 *
533 * @param string $html_message
534 * @param array $contact
535 * @param array $contribution
536 * @param array $messageToken
537 * @param bool $grouped
538 * Does this letter represent more than one contribution.
539 * @param string $separator
540 * What is the preferred letter separator.
541 * @param array $contributions
542 *
543 * @return string
544 */
545 protected function resolveTokens(string $html_message, $contact, $contribution, $messageToken, $grouped, $separator, $contributions): string {
546 if ($grouped) {
547 $tokenHtml = CRM_Utils_Token::replaceMultipleContributionTokens($separator, $html_message, $contributions, $messageToken);
548 }
549 else {
550 // no change to normal behaviour to avoid risk of breakage
551 $tokenHtml = CRM_Utils_Token::replaceContributionTokens($html_message, $contribution, TRUE, $messageToken);
552 }
553 $tokenContext = [
554 'smarty' => (defined('CIVICRM_MAIL_SMARTY') && CIVICRM_MAIL_SMARTY),
555 'contactId' => $contact['contact_id'],
556 ];
557 $smarty = ['contact' => $contact];
558 return CRM_Core_TokenSmarty::render(['html' => $tokenHtml], $tokenContext, $smarty)['html'];
559 }
560
561 }