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