3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
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 +--------------------------------------------------------------------+
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
19 * This class provides the functionality to create PDF letter for a group of contacts or a single contact.
21 class CRM_Contribute_Form_Task_PDFLetter
extends CRM_Contribute_Form_Task
{
23 use CRM_Contact_Form_Task_PDFTrait
;
26 * All the existing templates in the system.
30 public $_templates = NULL;
32 public $_single = NULL;
37 * Build all the data structures needed to build the form.
39 public function preProcess() {
40 $this->skipOnHold
= $this->skipDeceased
= FALSE;
41 $this->preProcessPDF();
43 $this->assign('single', $this->isSingle());
47 * This virtual function is used to set the default values of
48 * various form elements
53 * reference to the array of default values
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;
67 $defaults['thankyou_update'] = 1;
73 * Build the form object.
75 * @throws \CRM_Core_Exception
77 public function buildQuickForm() {
79 $this->assign('suppressForm', FALSE);
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();
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);
90 // Group options for tokens are not yet implemented. dgg
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'),
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'];
103 $this->addElement('select', 'group_by_separator', ts('Separator (grouped contributions)'), $separatorOptions);
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.'),
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.');
113 $this->addElement('select', 'email_options', ts('Print and email options'), $emailOptions, [], "<br/>", FALSE);
118 'name' => ts('Make Thank-you Letters'),
123 'name' => ts('Done'),
130 * Process the form after the input has been submitted and validated.
132 * @throws \CRM_Core_Exception
133 * @throws \CiviCRM_API3_Exception
135 public function postProcess() {
136 $formValues = $this->controller
->exportValues($this->getName());
137 [$formValues, $html_message, $messageToken, $returnProperties] = $this->processMessageTemplate($formValues);
140 if (!empty($formValues['email_options'])) {
141 $returnProperties['email'] = $returnProperties['on_hold'] = $returnProperties['is_deceased'] = $returnProperties['do_not_email'] = 1;
143 'subject' => $formValues['subject'] ??
NULL,
144 'from' => $formValues['from_email_address'] ??
NULL,
147 $emailParams['from'] = CRM_Utils_Mail
::formatFromAddress($emailParams['from']);
149 // We need display_name for emailLetter() so add to returnProperties here
150 $returnProperties['display_name'] = 1;
151 if (stristr($formValues['email_options'], 'pdfemail')) {
156 $receipt_update = $formValues['receipt_update'] ??
FALSE;
157 $thankyou_update = $formValues['thankyou_update'] ??
FALSE;
158 $nowDate = date('YmdHis');
159 $receipts = $thanks = $emailed = 0;
161 $task = 'CRM_Contribution_Form_Task_PDFLetterCommon';
162 $realSeparator = ', ';
165 'tr' => '</td></tr><tr><td>',
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']];
173 elseif ($formValues['group_by_separator'] == 'br') {
174 $realSeparator = "<br />";
177 // a placeholder in case the separator is common in the string - e.g ', '
178 $separator = '****~~~~';
179 $groupBy = $this->getSubmittedValue('group_by');
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
;
193 [$contributions, $contacts] = $this->buildContributionArray($groupBy, $contributionIDs, $returnProperties, $skipOnHold, $skipDeceased, $messageToken, $task, $separator, $this->isQueryIncludesSoftCredits());
195 $contactHtml = $emailedHtml = [];
196 foreach ($contributions as $contributionId => $contribution) {
197 $contact = &$contacts[$contribution['contact_id']];
201 $groupByID = empty($contribution[$groupBy]) ?
0 : $contribution[$groupBy];
202 $contribution = $contact['combined'][$groupBy][$groupByID];
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)) {
212 if (!stristr($formValues['email_options'], 'both')) {
213 $emailedHtml[$contributionId] = TRUE;
217 $contact['is_sent'][$groupBy][$groupByID] = TRUE;
219 // Update receipt/thankyou dates
220 $contributionParams = ['id' => $contributionId];
221 if ($receipt_update) {
222 $contributionParams['receipt_date'] = $nowDate;
224 if ($thankyou_update) {
225 $contributionParams['thankyou_date'] = $nowDate;
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);
234 $contactIds = array_keys($contacts);
235 $this->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);
240 $fileName = $this->getFileName();
241 if ($this->getSubmittedValue('document_type') === 'pdf') {
242 CRM_Utils_PDF_Utils
::html2pdf($html, $fileName . '.pdf', FALSE, $formValues);
245 CRM_Utils_PDF_Document
::html2doc($html, $fileName . '.' . $this->getSubmittedValue('document_type'), $formValues);
249 $this->postProcessHook();
252 $updateStatus = ts('Receipts have been emailed to %1 contributions.', [1 => $emailed]);
255 $updateStatus = ts('Receipt date has been updated for %1 contributions.', [1 => $receipts]);
258 $updateStatus .= ' ' . ts('Thank-you date has been updated for %1 contributions.', [1 => $thanks]);
262 CRM_Core_Session
::setStatus($updateStatus);
265 // ie. we have only sent emails - lets no show a white screen
266 CRM_Utils_System
::civiExit();
271 * List available tokens for this form.
275 public function listTokens() {
276 $tokens = CRM_Core_SelectValues
::contactTokens();
277 $tokens = array_merge(CRM_Core_SelectValues
::contributionTokens(), $tokens);
278 $tokens = array_merge(CRM_Core_SelectValues
::domainTokens(), $tokens);
283 * Generate the contribution array from the form, we fill in the contact details and determine any aggregation
284 * around contact_id of contribution_recur_id
286 * @param string $groupBy
287 * @param array $contributionIDs
288 * @param array $returnProperties
289 * @param bool $skipOnHold
290 * @param bool $skipDeceased
291 * @param array $messageToken
292 * @param string $task
293 * @param string $separator
294 * @param bool $isIncludeSoftCredits
298 public function buildContributionArray($groupBy, $contributionIDs, $returnProperties, $skipOnHold, $skipDeceased, $messageToken, $task, $separator, $isIncludeSoftCredits) {
299 $contributions = $contacts = [];
300 foreach ($contributionIDs as $item => $contributionId) {
301 $contribution = CRM_Contribute_BAO_Contribution
::getContributionTokenValues($contributionId, $messageToken)['values'][$contributionId];
302 $contribution['campaign'] = $contribution['contribution_campaign_title'] ??
NULL;
303 $contributions[$contributionId] = $contribution;
305 if ($isIncludeSoftCredits) {
306 //@todo find out why this happens & add comments
307 [$contactID] = explode('-', $item);
308 $contactID = (int) $contactID;
311 $contactID = $contribution['contact_id'];
313 if (!isset($contacts[$contactID])) {
314 $contacts[$contactID] = [];
315 $contacts[$contactID]['contact_aggregate'] = 0;
316 $contacts[$contactID]['combined'] = $contacts[$contactID]['contribution_ids'] = [];
319 $contacts[$contactID]['contact_aggregate'] +
= $contribution['total_amount'];
320 $groupByID = empty($contribution[$groupBy]) ?
0 : $contribution[$groupBy];
322 $contacts[$contactID]['contribution_ids'][$groupBy][$groupByID][$contributionId] = TRUE;
323 if (!isset($contacts[$contactID]['combined'][$groupBy]) ||
!isset($contacts[$contactID]['combined'][$groupBy][$groupByID])) {
324 $contacts[$contactID]['combined'][$groupBy][$groupByID] = $contribution;
325 $contacts[$contactID]['aggregates'][$groupBy][$groupByID] = $contribution['total_amount'];
328 $contacts[$contactID]['combined'][$groupBy][$groupByID] = self
::combineContributions($contacts[$contactID]['combined'][$groupBy][$groupByID], $contribution, $separator);
329 $contacts[$contactID]['aggregates'][$groupBy][$groupByID] +
= $contribution['total_amount'];
332 // Assign the available contributions before calling tokens so hooks parsing smarty can access it.
333 // Note that in core code you can only use smarty here if enable if for the whole site, incl
334 // CiviMail, with a big performance impact.
335 // Hooks allow more nuanced smarty usage here.
336 CRM_Core_Smarty
::singleton()->assign('contributions', $contributions);
337 foreach ($contacts as $contactID => $contact) {
338 [$tokenResolvedContacts] = CRM_Utils_Token
::getTokenDetails(['contact_id' => $contactID],
346 $contacts[$contactID] = array_merge($tokenResolvedContacts[$contactID], $contact);
348 return [$contributions, $contacts];
352 * We combine the contributions by adding the contribution to each field with the separator in
353 * between the existing value and the new one. We put the separator there even if empty so it is clear what the
354 * value for previous contributions was
356 * @param array $existing
357 * @param array $contribution
358 * @param string $separator
362 public static function combineContributions($existing, $contribution, $separator) {
363 foreach ($contribution as $field => $value) {
364 $existing[$field] = isset($existing[$field]) ?
$existing[$field] . $separator : '';
365 $existing[$field] .= $value;
371 * We are going to retrieve the combined contribution and if smarty mail is enabled we
372 * will also assign an array of contributions for this contact to the smarty template
374 * @param array $contact
375 * @param array $contributions
377 * @param int $groupByID
379 public static function assignCombinedContributionValues($contact, $contributions, $groupBy, $groupByID) {
380 CRM_Core_Smarty
::singleton()->assign('contact_aggregate', $contact['contact_aggregate']);
381 CRM_Core_Smarty
::singleton()
382 ->assign('contributions', $contributions);
383 CRM_Core_Smarty
::singleton()->assign('contribution_aggregate', $contact['aggregates'][$groupBy][$groupByID]);
389 * @param $contribution
391 * @param $contributions
392 * @param $realSeparator
393 * @param $tableSeparators
394 * @param $messageToken
395 * @param $html_message
398 * @param bool $grouped
399 * @param int $groupByID
402 * @throws \CRM_Core_Exception
404 public function generateHtml(&$contact, $contribution, $groupBy, $contributions, $realSeparator, $tableSeparators, $messageToken, $html_message, $separator, $grouped, $groupByID) {
405 static $validated = FALSE;
408 $groupedContributions = array_intersect_key($contributions, $contact['contribution_ids'][$groupBy][$groupByID]);
409 CRM_Contribute_Form_Task_PDFLetter
::assignCombinedContributionValues($contact, $groupedContributions, $groupBy, $groupByID);
411 if (empty($groupBy) ||
empty($contact['is_sent'][$groupBy][$groupByID])) {
412 if (!$validated && in_array($realSeparator, $tableSeparators) && !CRM_Contribute_Form_Task_PDFLetter
::isValidHTMLWithTableSeparator($messageToken, $html_message)) {
413 $realSeparator = ', ';
414 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.'));
417 $html = str_replace($separator, $realSeparator, $this->resolveTokens($html_message, $contact, $contribution, $messageToken, $grouped, $separator, $groupedContributions));
426 * @param array $contact
427 * @param string $html
430 * @param array $format
431 * @param array $params
435 public function emailLetter($contact, $html, $is_pdf, $format = [], $params = []) {
437 if (empty($contact['email'])) {
440 $mustBeEmpty = ['do_not_email', 'is_deceased', 'on_hold'];
441 foreach ($mustBeEmpty as $emptyField) {
442 if (!empty($contact[$emptyField])) {
448 'toName' => $contact['display_name'],
449 'toEmail' => $contact['email'],
453 if (empty($params['from'])) {
454 $emails = CRM_Core_BAO_Email
::getFromEmail();
455 $emails = array_keys($emails);
456 $defaults['from'] = array_pop($emails);
459 $defaults['from'] = $params['from'];
461 if (!empty($params['subject'])) {
462 $defaults['subject'] = $params['subject'];
465 $defaults['subject'] = ts('Thank you for your contribution/s');
468 $defaults['html'] = ts('Please see attached');
469 $defaults['attachments'] = [CRM_Utils_Mail
::appendPDF('ThankYou.pdf', $html, $format)];
471 $params = array_merge($defaults);
472 return CRM_Utils_Mail
::send($params);
474 catch (CRM_Core_Exception
$e) {
480 * Check that the token only appears in a table cell. The '</td><td>' separator cannot otherwise work
481 * Calculate the number of times it appears IN the cell & the number of times it appears - should be the same!
483 * @param string $token
484 * @param string $entity
485 * @param string $textToSearch
489 public static function isHtmlTokenInTableCell($token, $entity, $textToSearch) {
490 $tokenToMatch = $entity . '\.' . $token;
491 $pattern = '|<td(?![\w-])((?!</td>).)*\{' . $tokenToMatch . '\}.*?</td>|si';
492 $within = preg_match_all($pattern, $textToSearch);
493 $total = preg_match_all("|{" . $tokenToMatch . "}|", $textToSearch);
494 return ($within == $total);
498 * Check whether any of the tokens exist in the html outside a table cell.
499 * If they do the table cell separator is not supported (return false)
500 * At this stage we are only anticipating contributions passed in this way but
501 * it would be easy to add others
507 public static function isValidHTMLWithTableSeparator($tokens, $html) {
508 $relevantEntities = ['contribution'];
509 foreach ($relevantEntities as $entity) {
510 if (isset($tokens[$entity]) && is_array($tokens[$entity])) {
511 foreach ($tokens[$entity] as $token) {
512 if (!CRM_Contribute_Form_Task_PDFLetter
::isHtmlTokenInTableCell($token, $entity, $html)) {
523 * @param string $html_message
524 * @param array $contact
525 * @param array $contribution
526 * @param array $messageToken
527 * @param bool $grouped
528 * Does this letter represent more than one contribution.
529 * @param string $separator
530 * What is the preferred letter separator.
531 * @param array $contributions
535 protected function resolveTokens(string $html_message, $contact, $contribution, $messageToken, $grouped, $separator, $contributions): string {
537 $tokenHtml = CRM_Utils_Token
::replaceMultipleContributionTokens($separator, $html_message, $contributions, $messageToken);
540 // no change to normal behaviour to avoid risk of breakage
541 $tokenHtml = CRM_Utils_Token
::replaceContributionTokens($html_message, $contribution, TRUE, $messageToken);
544 'smarty' => (defined('CIVICRM_MAIL_SMARTY') && CIVICRM_MAIL_SMARTY
),
545 'contactId' => $contact['contact_id'],
547 $smarty = ['contact' => $contact];
548 return CRM_Core_TokenSmarty
::render(['html' => $tokenHtml], $tokenContext, $smarty)['html'];