skipOnHold = $this->skipDeceased = FALSE;
$this->preProcessPDF();
parent::preProcess();
$this->assign('single', $this->isSingle());
}
/**
* This virtual function is used to set the default values of
* various form elements
*
* access public
*
* @return array
* reference to the array of default values
*/
/**
* @return array
*/
public function setDefaultValues() {
$defaults = $this->getPDFDefaultValues();
if (isset($this->_activityId)) {
$params = ['id' => $this->_activityId];
CRM_Activity_BAO_Activity::retrieve($params, $defaults);
$defaults['html_message'] = $defaults['details'] ?? NULL;
}
else {
$defaults['thankyou_update'] = 1;
}
return $defaults;
}
/**
* Build the form object.
*
* @throws \CRM_Core_Exception
*/
public function buildQuickForm() {
//enable form element
$this->assign('suppressForm', FALSE);
// Contribute PDF tasks allow you to email as well, so we need to add email address to those forms
$this->add('select', 'from_email_address', ts('From Email Address'), $this->_fromEmails, TRUE);
$this->addPDFElementsToForm();
// specific need for contributions
$this->add('static', 'more_options_header', NULL, ts('Thank-you Letter Options'));
$this->add('checkbox', 'receipt_update', ts('Update receipt dates for these contributions'), FALSE);
$this->add('checkbox', 'thankyou_update', ts('Update thank-you dates for these contributions'), FALSE);
// Group options for tokens are not yet implemented. dgg
$options = [
'' => ts('- no grouping -'),
'contact_id' => ts('Contact'),
'contribution_recur_id' => ts('Contact and Recurring'),
'financial_type_id' => ts('Contact and Financial Type'),
'campaign_id' => ts('Contact and Campaign'),
'payment_instrument_id' => ts('Contact and Payment Method'),
];
$this->addElement('select', 'group_by', ts('Group contributions by'), $options, [], "
", FALSE);
// this was going to be free-text but I opted for radio options in case there was a script injection risk
$separatorOptions = ['comma' => 'Comma', 'td' => 'Horizontal Table Cell', 'tr' => 'Vertical Table Cell', 'br' => 'Line Break'];
$this->addElement('select', 'group_by_separator', ts('Separator (grouped contributions)'), $separatorOptions);
$emailOptions = [
'' => ts('Generate PDFs for printing (only)'),
'email' => ts('Send emails where possible. Generate printable PDFs for contacts who cannot receive email.'),
'both' => ts('Send emails where possible. Generate printable PDFs for all contacts.'),
];
if (CRM_Core_Config::singleton()->doNotAttachPDFReceipt) {
$emailOptions['pdfemail'] = ts('Send emails with an attached PDF where possible. Generate printable PDFs for contacts who cannot receive email.');
$emailOptions['pdfemail_both'] = ts('Send emails with an attached PDF where possible. Generate printable PDFs for all contacts.');
}
$this->addElement('select', 'email_options', ts('Print and email options'), $emailOptions, [], "
", FALSE);
$this->addButtons($this->getButtons());
}
/**
* Get the name for the main submit button.
*
* @return string
*/
protected function getMainSubmitButtonName(): string {
return ts('Make Thank-you Letters');
}
/**
* Process the form after the input has been submitted and validated.
*
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
*/
public function postProcess() {
$formValues = $this->controller->exportValues($this->getName());
[$formValues, $html_message] = $this->processMessageTemplate($formValues);
$messageToken = CRM_Utils_Token::getTokens($html_message);
$returnProperties = [];
if (isset($messageToken['contact'])) {
foreach ($messageToken['contact'] as $key => $value) {
$returnProperties[$value] = 1;
}
}
$isPDF = FALSE;
$emailParams = [];
if (!empty($formValues['email_options'])) {
$returnProperties['email'] = $returnProperties['on_hold'] = $returnProperties['is_deceased'] = $returnProperties['do_not_email'] = 1;
$emailParams = [
'subject' => $formValues['subject'] ?? NULL,
'from' => $formValues['from_email_address'] ?? NULL,
];
$emailParams['from'] = CRM_Utils_Mail::formatFromAddress($emailParams['from']);
// We need display_name for emailLetter() so add to returnProperties here
$returnProperties['display_name'] = 1;
if (stristr($formValues['email_options'], 'pdfemail')) {
$isPDF = TRUE;
}
}
// update dates ?
$receipt_update = $formValues['receipt_update'] ?? FALSE;
$thankyou_update = $formValues['thankyou_update'] ?? FALSE;
$nowDate = date('YmdHis');
$receipts = $thanks = $emailed = 0;
$updateStatus = '';
$task = 'CRM_Contribution_Form_Task_PDFLetterCommon';
$realSeparator = ', ';
$tableSeparators = [
'td' => '
',
];
//the original thinking was mutliple options - but we are going with only 2 (comma & td) for now in case
// there are security (& UI) issues we need to think through
if (isset($formValues['group_by_separator'])) {
if (in_array($formValues['group_by_separator'], ['td', 'tr'])) {
$realSeparator = $tableSeparators[$formValues['group_by_separator']];
}
elseif ($formValues['group_by_separator'] == 'br') {
$realSeparator = " ";
}
}
// a placeholder in case the separator is common in the string - e.g ', '
$separator = '****~~~~';
$groupBy = $this->getSubmittedValue('group_by');
// skip some contacts ?
$skipOnHold = $this->skipOnHold ?? FALSE;
$skipDeceased = $this->skipDeceased ?? TRUE;
$contributionIDs = $this->getIDs();
if ($this->isQueryIncludesSoftCredits()) {
$contributionIDs = [];
$result = $this->getSearchQueryResults();
while ($result->fetch()) {
$this->_contactIds[$result->contact_id] = $result->contact_id;
$contributionIDs["{$result->contact_id}-{$result->contribution_id}"] = $result->contribution_id;
}
}
[$contributions, $contacts] = $this->buildContributionArray($groupBy, $contributionIDs, $returnProperties, $skipOnHold, $skipDeceased, $messageToken, $task, $separator, $this->isQueryIncludesSoftCredits());
$html = [];
$contactHtml = $emailedHtml = [];
foreach ($contributions as $contributionId => $contribution) {
$contact = &$contacts[$contribution['contact_id']];
$grouped = FALSE;
$groupByID = 0;
if ($groupBy) {
$groupByID = empty($contribution[$groupBy]) ? 0 : $contribution[$groupBy];
$contribution = $contact['combined'][$groupBy][$groupByID];
$grouped = TRUE;
}
if (empty($groupBy) || empty($contact['is_sent'][$groupBy][$groupByID])) {
$html[$contributionId] = $this->generateHtml($contact, $contribution, $groupBy, $contributions, $realSeparator, $tableSeparators, $messageToken, $html_message, $separator, $grouped, $groupByID);
$contactHtml[$contact['contact_id']][] = $html[$contributionId];
if ($this->isSendEmails()) {
if ($this->emailLetter($contact, $html[$contributionId], $isPDF, $formValues, $emailParams)) {
$emailed++;
if (!stristr($formValues['email_options'], 'both')) {
$emailedHtml[$contributionId] = TRUE;
}
}
}
$contact['is_sent'][$groupBy][$groupByID] = TRUE;
}
if ($this->isLiveMode()) {
// Update receipt/thankyou dates
$contributionParams = ['id' => $contributionId];
if ($receipt_update) {
$contributionParams['receipt_date'] = $nowDate;
}
if ($thankyou_update) {
$contributionParams['thankyou_date'] = $nowDate;
}
if ($receipt_update || $thankyou_update) {
civicrm_api3('Contribution', 'create', $contributionParams);
$receipts = ($receipt_update ? $receipts + 1 : $receipts);
$thanks = ($thankyou_update ? $thanks + 1 : $thanks);
}
}
}
$contactIds = array_keys($contacts);
// CRM-16725 Skip creation of activities if user is previewing their PDF letter(s)
if ($this->isLiveMode()) {
$this->createActivities($html_message, $contactIds, CRM_Utils_Array::value('subject', $formValues, ts('Thank you letter')), CRM_Utils_Array::value('campaign_id', $formValues), $contactHtml);
}
$html = array_diff_key($html, $emailedHtml);
//CRM-19761
if (!empty($html)) {
$fileName = $this->getFileName();
if ($this->getSubmittedValue('document_type') === 'pdf') {
CRM_Utils_PDF_Utils::html2pdf($html, $fileName . '.pdf', FALSE, $formValues);
}
else {
CRM_Utils_PDF_Document::html2doc($html, $fileName . '.' . $this->getSubmittedValue('document_type'), $formValues);
}
}
$this->postProcessHook();
if ($emailed) {
$updateStatus = ts('Receipts have been emailed to %1 contributions.', [1 => $emailed]);
}
if ($receipts) {
$updateStatus = ts('Receipt date has been updated for %1 contributions.', [1 => $receipts]);
}
if ($thanks) {
$updateStatus .= ' ' . ts('Thank-you date has been updated for %1 contributions.', [1 => $thanks]);
}
if ($updateStatus) {
CRM_Core_Session::setStatus($updateStatus);
}
if (!empty($html)) {
// ie. we have only sent emails - lets no show a white screen
CRM_Utils_System::civiExit();
}
}
/**
* Are emails to be sent out?
*
* @return bool
*/
protected function isSendEmails(): bool {
return $this->isLiveMode() && $this->getSubmittedValue('email_options');
}
/**
* Get the token processor schema required to list any tokens for this task.
*
* @return array
*/
public function getTokenSchema(): array {
return ['contributionId', 'contactId'];
}
/**
* Generate the contribution array from the form, we fill in the contact details and determine any aggregation
* around contact_id of contribution_recur_id
*
* @param string $groupBy
* @param array $contributionIDs
* @param array $returnProperties
* @param bool $skipOnHold
* @param bool $skipDeceased
* @param array $messageToken
* @param string $task
* @param string $separator
* @param bool $isIncludeSoftCredits
*
* @return array
* @throws \CiviCRM_API3_Exception
*/
public function buildContributionArray($groupBy, $contributionIDs, $returnProperties, $skipOnHold, $skipDeceased, $messageToken, $task, $separator, $isIncludeSoftCredits) {
$contributions = $contacts = [];
foreach ($contributionIDs as $item => $contributionId) {
$contribution = CRM_Contribute_BAO_Contribution::getContributionTokenValues($contributionId, $messageToken)['values'][$contributionId];
$contribution['campaign'] = $contribution['contribution_campaign_title'] ?? NULL;
$contributions[$contributionId] = $contribution;
if ($isIncludeSoftCredits) {
//@todo find out why this happens & add comments
[$contactID] = explode('-', $item);
$contactID = (int) $contactID;
}
else {
$contactID = $contribution['contact_id'];
}
if (!isset($contacts[$contactID])) {
$contacts[$contactID] = [];
$contacts[$contactID]['contact_aggregate'] = 0;
$contacts[$contactID]['combined'] = $contacts[$contactID]['contribution_ids'] = [];
}
$contacts[$contactID]['contact_aggregate'] += $contribution['total_amount'];
$groupByID = empty($contribution[$groupBy]) ? 0 : $contribution[$groupBy];
$contacts[$contactID]['contribution_ids'][$groupBy][$groupByID][$contributionId] = TRUE;
if (!isset($contacts[$contactID]['combined'][$groupBy]) || !isset($contacts[$contactID]['combined'][$groupBy][$groupByID])) {
$contacts[$contactID]['combined'][$groupBy][$groupByID] = $contribution;
$contacts[$contactID]['aggregates'][$groupBy][$groupByID] = $contribution['total_amount'];
}
else {
$contacts[$contactID]['combined'][$groupBy][$groupByID] = self::combineContributions($contacts[$contactID]['combined'][$groupBy][$groupByID], $contribution, $separator);
$contacts[$contactID]['aggregates'][$groupBy][$groupByID] += $contribution['total_amount'];
}
}
// Assign the available contributions before calling tokens so hooks parsing smarty can access it.
// Note that in core code you can only use smarty here if enable if for the whole site, incl
// CiviMail, with a big performance impact.
// Hooks allow more nuanced smarty usage here.
CRM_Core_Smarty::singleton()->assign('contributions', $contributions);
$resolvedContacts = civicrm_api3('Contact', 'get', [
'return' => array_keys($returnProperties),
'id' => ['IN' => array_keys($contacts)],
'options' => ['limit' => 0],
])['values'];
foreach ($contacts as $contactID => $contact) {
$contacts[$contactID] = array_merge($resolvedContacts[$contactID], $contact);
}
return [$contributions, $contacts];
}
/**
* We combine the contributions by adding the contribution to each field with the separator in
* between the existing value and the new one. We put the separator there even if empty so it is clear what the
* value for previous contributions was
*
* @param array $existing
* @param array $contribution
* @param string $separator
*
* @return array
*/
public static function combineContributions($existing, $contribution, $separator) {
foreach ($contribution as $field => $value) {
$existing[$field] = isset($existing[$field]) ? $existing[$field] . $separator : '';
$existing[$field] .= $value;
}
return $existing;
}
/**
* We are going to retrieve the combined contribution and if smarty mail is enabled we
* will also assign an array of contributions for this contact to the smarty template
*
* @param array $contact
* @param array $contributions
* @param $groupBy
* @param int $groupByID
*/
public static function assignCombinedContributionValues($contact, $contributions, $groupBy, $groupByID) {
CRM_Core_Smarty::singleton()->assign('contact_aggregate', $contact['contact_aggregate']);
CRM_Core_Smarty::singleton()
->assign('contributions', $contributions);
CRM_Core_Smarty::singleton()->assign('contribution_aggregate', $contact['aggregates'][$groupBy][$groupByID]);
}
/**
* @param $contact
* @param $formValues
* @param $contribution
* @param $groupBy
* @param $contributions
* @param $realSeparator
* @param $tableSeparators
* @param $messageToken
* @param $html_message
* @param $separator
* @param $categories
* @param bool $grouped
* @param int $groupByID
*
* @return string
* @throws \CRM_Core_Exception
*/
public function generateHtml($contact, $contribution, $groupBy, $contributions, $realSeparator, $tableSeparators, $messageToken, $html_message, $separator, $grouped, $groupByID) {
static $validated = FALSE;
$html = NULL;
$groupedContributions = array_intersect_key($contributions, $contact['contribution_ids'][$groupBy][$groupByID]);
CRM_Contribute_Form_Task_PDFLetter::assignCombinedContributionValues($contact, $groupedContributions, $groupBy, $groupByID);
if (empty($groupBy) || empty($contact['is_sent'][$groupBy][$groupByID])) {
if (!$validated && in_array($realSeparator, $tableSeparators) && !CRM_Contribute_Form_Task_PDFLetter::isValidHTMLWithTableSeparator($messageToken, $html_message)) {
$realSeparator = ', ';
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.'));
}
$validated = TRUE;
$html = str_replace($separator, $realSeparator, $this->resolveTokens($html_message, $contact['contact_id'], $contribution['id'], $grouped, $separator, $groupedContributions));
}
return $html;
}
/**
* Send pdf by email.
*
* @param array $contact
* @param string $html
*
* @param $is_pdf
* @param array $format
* @param array $params
*
* @return bool
*/
public function emailLetter($contact, $html, $is_pdf, $format = [], $params = []) {
try {
if (empty($contact['email'])) {
return FALSE;
}
$mustBeEmpty = ['do_not_email', 'is_deceased', 'on_hold'];
foreach ($mustBeEmpty as $emptyField) {
if (!empty($contact[$emptyField])) {
return FALSE;
}
}
$defaults = [
'contactId' => $contact['id'],
'toName' => $contact['display_name'],
'toEmail' => $contact['email'],
'text' => '',
'html' => $html,
];
if (empty($params['from'])) {
$emails = CRM_Core_BAO_Email::getFromEmail();
$emails = array_keys($emails);
$defaults['from'] = array_pop($emails);
}
else {
$defaults['from'] = $params['from'];
}
if (!empty($params['subject'])) {
$defaults['subject'] = $params['subject'];
}
else {
$defaults['subject'] = ts('Thank you for your contribution/s');
}
if ($is_pdf) {
$defaults['html'] = ts('Please see attached');
$defaults['attachments'] = [CRM_Utils_Mail::appendPDF('ThankYou.pdf', $html, $format)];
}
return CRM_Utils_Mail::send($defaults);
}
catch (CRM_Core_Exception $e) {
return FALSE;
}
}
/**
* Check that the token only appears in a table cell. The ' | ' separator cannot otherwise work
* Calculate the number of times it appears IN the cell & the number of times it appears - should be the same!
*
* @param string $token
* @param string $entity
* @param string $textToSearch
*
* @return bool
*/
public static function isHtmlTokenInTableCell($token, $entity, $textToSearch) {
$tokenToMatch = $entity . '\.' . $token;
$pattern = '| | ).)*\{' . $tokenToMatch . '\}.*? | |si';
$within = preg_match_all($pattern, $textToSearch);
$total = preg_match_all("|{" . $tokenToMatch . "}|", $textToSearch);
return ($within == $total);
}
/**
* Check whether any of the tokens exist in the html outside a table cell.
* If they do the table cell separator is not supported (return false)
* At this stage we are only anticipating contributions passed in this way but
* it would be easy to add others
* @param $tokens
* @param $html
*
* @return bool
*/
public static function isValidHTMLWithTableSeparator($tokens, $html) {
$relevantEntities = ['contribution'];
foreach ($relevantEntities as $entity) {
if (isset($tokens[$entity]) && is_array($tokens[$entity])) {
foreach ($tokens[$entity] as $token) {
if (!CRM_Contribute_Form_Task_PDFLetter::isHtmlTokenInTableCell($token, $entity, $html)) {
return FALSE;
}
}
}
}
return TRUE;
}
/**
*
* @param string $html_message
* @param int $contactID
* @param int $contributionID
* @param bool $grouped
* Does this letter represent more than one contribution.
* @param string $separator
* What is the preferred letter separator.
* @param array $contributions
*
* @return string
*/
protected function resolveTokens(string $html_message, int $contactID, $contributionID, $grouped, $separator, $contributions): string {
$tokenContext = [
'smarty' => (defined('CIVICRM_MAIL_SMARTY') && CIVICRM_MAIL_SMARTY),
'contactId' => $contactID,
'schema' => ['contributionId'],
];
if ($grouped) {
// First replace the contribution tokens. These are pretty ... special.
// if the text looks like `{contribution.currency} {contribution.total_amount} | '
// and there are 2 rows with a currency separator of
// you wind up with a string like
// 'USD | USD> | $50 | $89 |
// see https://docs.civicrm.org/user/en/latest/contributions/manual-receipts-and-thank-yous/#grouped-contribution-thank-you-letters
$tokenProcessor = new TokenProcessor(\Civi::dispatcher(), $tokenContext);
$contributionTokens = CRM_Utils_Token::getTokens($html_message)['contribution'] ?? [];
foreach ($contributionTokens as $token) {
$tokenProcessor->addMessage($token, '{contribution.' . $token . '}', 'text/html');
}
foreach ($contributions as $contribution) {
$tokenProcessor->addRow([
'contributionId' => $contribution['id'],
'contribution' => $contribution,
]);
}
$tokenProcessor->evaluate();
$resolvedTokens = [];
foreach ($contributionTokens as $token) {
foreach ($tokenProcessor->getRows() as $row) {
$resolvedTokens[$token][$row->context['contributionId']] = $row->render($token);
}
$html_message = str_replace('{contribution.' . $token . '}', implode($separator, $resolvedTokens[$token]), $html_message);
}
}
$tokenContext['contributionId'] = $contributionID;
return CRM_Core_TokenSmarty::render(['html' => $html_message], $tokenContext)['html'];
}
}