PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.4.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.4.0
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / pdf / receipt-generator.php

receipt-generator.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.4.0, at inc/pdf/receipt-generator.php

333 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * PDF Receipt Generator.
4 *
5 * Generates PDF donation receipts using the mPDF library.
6 *
7 * @package SureDonation
8 */
9
10 namespace SureDonation\Inc\Pdf;
11
12 use SureDonation\Inc\Database\Tables\Donations;
13 use SureDonation\Inc\Database\Tables\Donors;
14 use SureDonation\Inc\Helper;
15 use SureDonation\Inc\Payments\Payment_Helper;
16
17 // Exit if accessed directly.
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 /**
23 * Receipt_Generator class.
24 *
25 * @since 1.0.0
26 */
27 class Receipt_Generator {
28 /**
29 * Get an existing receipt PDF or generate a new one.
30 *
31 * @param int $donation_id Donation ID.
32 * @return string|false File path on success, false on failure.
33 * @since 1.0.0
34 */
35 public static function get_or_generate( $donation_id ) {
36 $donation = Donations::get( $donation_id );
37
38 if ( ! $donation ) {
39 return false;
40 }
41
42 // Check if a cached PDF exists.
43 $existing_relative = $donation['receipt_pdf_url'] ?? '';
44
45 if ( ! empty( $existing_relative ) ) {
46 $existing_path = self::relative_to_path( Helper::get_string_value( $existing_relative ) );
47
48 if ( $existing_path && file_exists( $existing_path ) ) {
49 return $existing_path;
50 }
51 }
52
53 return self::generate( $donation_id );
54 }
55
56 /**
57 * Generate a PDF receipt for a donation.
58 *
59 * @param int $donation_id Donation ID.
60 * @return string|false File path on success, false on failure.
61 * @since 1.0.0
62 */
63 public static function generate( $donation_id ) {
64 if ( ! Pdf_Utils::check_if_library_exists() || ! Pdf_Utils::is_php_compatible() ) {
65 return false;
66 }
67
68 // Load the mPDF autoloader.
69 require_once Pdf_Utils::get_library_path() . '/vendor/autoload.php';
70
71 $donation = Donations::get( $donation_id );
72
73 if ( ! $donation ) {
74 return false;
75 }
76
77 $donor_id = Helper::get_integer_value( $donation['donor_id'] ?? 0 );
78 $donor = $donor_id ? Donors::get( $donor_id ) : null;
79
80 $campaign_id = Helper::get_integer_value( $donation['campaign_id'] ?? 0 );
81 $campaign_title = $campaign_id ? (string) get_the_title( $campaign_id ) : '';
82
83 // Build the receipt HTML.
84 $html = self::build_receipt_html( $donation, $donor, $campaign_title );
85
86 /**
87 * Filter the receipt HTML before PDF generation.
88 *
89 * SECURITY NOTE: The returned HTML is passed directly to mPDF's WriteHTML().
90 * mPDF can process <img> tags with file:// URIs and load external resources.
91 * Ensure any modifications only use trusted, escaped content.
92 *
93 * @param string $html Receipt HTML.
94 * @param array $donation Donation data.
95 * @param array|null $donor Donor data.
96 * @since 1.0.0
97 */
98 $html = apply_filters( 'suredonation_receipt_html', $html, $donation, $donor );
99
100 // Ensure receipts directory exists.
101 Pdf_Utils::ensure_receipts_dir();
102
103 $receipts_dir = Pdf_Utils::get_receipts_dir();
104 $filename = sprintf( 'suredonation-receipt-%d-%s.pdf', $donation_id, wp_generate_password( 8, false ) );
105 $filepath = $receipts_dir . '/' . $filename;
106
107 try {
108 $mpdf = new \Mpdf\Mpdf( self::get_mpdf_config() );
109 $mpdf->WriteHTML( $html );
110 $mpdf->Output( $filepath, \Mpdf\Output\Destination::FILE );
111 } catch ( \Exception $e ) {
112 return false;
113 }
114
115 // Store the relative path in the donation record (portable across domain changes).
116 $upload_dir = wp_upload_dir();
117 $relative_path = str_replace( $upload_dir['basedir'] . '/', '', $filepath );
118 Donations::update( $donation_id, [ 'receipt_pdf_url' => $relative_path ] );
119
120 return $filepath;
121 }
122
123 /**
124 * Delete a receipt PDF file by its stored uploads-relative path.
125 *
126 * Used by the personal-data eraser: the receipt is generated from the donor's
127 * name/email/address, so an erasure must remove the file from disk, not just
128 * the database columns.
129 *
130 * @since 1.2.0
131 * @param string $relative_path Relative path within the uploads directory.
132 * @return bool True when no file remains (deleted or never existed), false when it survived deletion.
133 */
134 public static function delete_receipt( $relative_path ) {
135 $filepath = self::relative_to_path( $relative_path );
136
137 if ( false === $filepath || ! file_exists( $filepath ) ) {
138 return true;
139 }
140
141 wp_delete_file( $filepath );
142
143 // Re-check with is_file() (not file_exists()) — wp_delete_file() has a
144 // filesystem side effect PHPStan can't see, so re-calling the already
145 // narrowed file_exists() reads as always-false to it.
146 clearstatcache( true, $filepath );
147
148 return ! is_file( $filepath );
149 }
150
151 /**
152 * Get the mPDF configuration.
153 *
154 * @return array<string,mixed>
155 * @since 1.0.0
156 */
157 private static function get_mpdf_config() {
158 return [
159 'mode' => 'utf-8',
160 'format' => 'A4',
161 'orientation' => 'P',
162 'margin_left' => 15,
163 'margin_right' => 15,
164 'margin_top' => 15,
165 'margin_bottom' => 15,
166 'default_font' => 'dejavusans',
167 'tempDir' => Pdf_Utils::get_temp_dir(),
168 // mPDF defaults allow <img src="file:///..."> and remote http(s)
169 // resource fetching. The receipt HTML is server-templated with
170 // escaped fields, but the suredonation_receipt_html filter (and
171 // any future ucfirst-only gateway label) would still surface
172 // donor / gateway data into HTML that mPDF processes. Disable
173 // the dangerous resource-loading defaults so a malicious string
174 // in any rendered field can't become SSRF (remote fetch) or
175 // LFI (local file read into the PDF) regardless of the source.
176 'allow_remote_dir_in_links_filesystem' => false,
177 'curlAllowUnsafeSslRequests' => false,
178 ];
179 }
180
181 /**
182 * Build the receipt HTML template.
183 *
184 * @param array<string, mixed> $donation Donation data.
185 * @param array<string, mixed>|null $donor Donor data.
186 * @param string $campaign_title Campaign title.
187 * @return string HTML content.
188 * @since 1.0.0
189 */
190 private static function build_receipt_html( $donation, $donor, $campaign_title ) {
191 $site_name = esc_html( get_bloginfo( 'name' ) );
192 $site_url = esc_url( site_url() );
193
194 $donation_id = Helper::get_integer_value( $donation['id'] ?? 0 );
195 // Prefer the name captured on this specific donation — it is the correct
196 // identity for a tax receipt and is unaffected by the donor record being
197 // set-once. Fall back to the donor record only when the donation itself
198 // carries no name.
199 $donation_donor_name = Helper::get_string_value( $donation['donor_name'] ?? '' );
200 $donor_name = esc_html( '' !== $donation_donor_name ? $donation_donor_name : Helper::get_string_value( $donor['name'] ?? '' ) );
201 $donor_email = esc_html( Helper::get_string_value( $donor['email'] ?? '' ) );
202 $payment_status = esc_html( ucfirst( Helper::get_string_value( $donation['payment_status'] ?? '' ) ) );
203 $payment_method = esc_html( ucfirst( Helper::get_string_value( $donation['gateway'] ?? '' ) ) );
204 $transaction_id = esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) );
205 $currency = Helper::get_string_value( $donation['currency'] ?? 'USD' );
206 $total = Helper::get_float_value( $donation['amount'] ?? 0 );
207 $fees_covered = Helper::get_float_value( $donation['fees_covered'] ?? 0 );
208 $amount = $total - $fees_covered;
209 $date = Helper::get_string_value( $donation['created_at'] ?? '' );
210
211 if ( ! empty( $date ) ) {
212 $date_format = Helper::get_string_value( get_option( 'date_format' ) );
213 $timestamp = strtotime( $date );
214 $formatted_date = false !== $timestamp ? wp_date( $date_format, $timestamp ) : false;
215 $date = is_string( $formatted_date ) ? $formatted_date : $date;
216 }
217
218 $campaign_title = esc_html( $campaign_title );
219
220 // Format amounts.
221 $formatted_amount = self::format_currency( $amount, $currency );
222 $formatted_fees = self::format_currency( $fees_covered, $currency );
223 $formatted_total = self::format_currency( $total, $currency );
224
225 // Build transaction ID row.
226 $transaction_row = '';
227 if ( ! empty( $transaction_id ) ) {
228 $transaction_row = sprintf(
229 '<tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">%s</td>
230 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">%s</td></tr>',
231 esc_html__( 'Transaction ID', 'suredonation' ),
232 $transaction_id
233 );
234 }
235
236 // Build fees row.
237 $fees_row = '';
238 if ( $fees_covered > 0 ) {
239 $fees_row = sprintf(
240 '<tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">%s</td>
241 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">%s</td></tr>',
242 esc_html__( 'Fees Covered', 'suredonation' ),
243 $formatted_fees
244 );
245 }
246
247 return '
248 <div style="max-width:560px;margin:0 auto;font-family:DejaVu Sans,sans-serif;color:#111827;">
249 <div style="text-align:center;margin-bottom:24px;padding-bottom:20px;border-bottom:2px solid #e5e7eb;">
250 <h1 style="font-size:20px;margin:0 0 4px;color:#111827;">' . $site_name . '</h1>
251 <p style="color:#6b7280;font-size:13px;margin:0;">' . esc_html__( 'Donation Receipt', 'suredonation' ) . '</p>
252 </div>
253
254 <p style="color:#6b7280;font-size:12px;margin:0 0 16px;text-align:right;">'
255 . esc_html__( 'Receipt', 'suredonation' ) . ' #' . $donation_id . '</p>
256
257 <table style="width:100%;border-collapse:collapse;margin-bottom:20px;border:1px solid #e5e7eb;border-radius:6px;">
258 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;width:40%;">'
259 . esc_html__( 'Donor Name', 'suredonation' ) . '</td>
260 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $donor_name . '</td></tr>
261 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">'
262 . esc_html__( 'Donor Email', 'suredonation' ) . '</td>
263 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $donor_email . '</td></tr>
264 </table>
265
266 <table style="width:100%;border-collapse:collapse;margin-bottom:20px;border:1px solid #e5e7eb;border-radius:6px;">
267 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;width:40%;">'
268 . esc_html__( 'Campaign Name', 'suredonation' ) . '</td>
269 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $campaign_title . '</td></tr>
270 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">'
271 . esc_html__( 'Payment Status', 'suredonation' ) . '</td>
272 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $payment_status . '</td></tr>
273 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">'
274 . esc_html__( 'Payment Method', 'suredonation' ) . '</td>
275 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $payment_method . '</td></tr>
276 ' . $transaction_row . '
277 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">'
278 . esc_html__( 'Donation Amount', 'suredonation' ) . '</td>
279 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $formatted_amount . '</td></tr>
280 ' . $fees_row . '
281 <tr><td style="padding:10px 12px;background:#f9fafb;font-weight:bold;color:#111827;font-size:13px;">'
282 . esc_html__( 'Donation Total', 'suredonation' ) . '</td>
283 <td style="padding:10px 12px;background:#f9fafb;font-weight:bold;font-size:13px;color:#111827;">' . $formatted_total . '</td></tr>
284 </table>
285
286 <p style="color:#6b7280;font-size:12px;margin:0 0 4px;">'
287 . esc_html__( 'Date', 'suredonation' ) . ': ' . esc_html( $date ) . '</p>
288
289 <div style="margin-top:30px;padding-top:16px;border-top:1px solid #e5e7eb;text-align:center;">
290 <p style="color:#9ca3af;font-size:11px;margin:0;">'
291 . sprintf(
292 /* translators: 1: Site name, 2: Site URL. */
293 esc_html__( 'Generated by %1$s · %2$s', 'suredonation' ),
294 $site_name,
295 $site_url
296 ) . '</p>
297 </div>
298 </div>';
299 }
300
301 /**
302 * Format a monetary amount with currency symbol.
303 *
304 * @param float $amount Amount to format.
305 * @param string $currency Currency code.
306 * @return string Formatted amount.
307 * @since 1.0.0
308 */
309 private static function format_currency( $amount, $currency = 'USD' ) {
310 // Delegate to the single source of truth so the currency symbol,
311 // decimal handling and sign position match every other surface
312 // (this replaces a divergent local symbol map).
313 return Payment_Helper::format_amount( $amount, $currency );
314 }
315
316 /**
317 * Convert a relative path to an absolute file path.
318 *
319 * @param string $relative_path Relative path within the uploads directory.
320 * @return string|false Absolute file path or false.
321 * @since 1.0.0
322 */
323 private static function relative_to_path( $relative_path ) {
324 if ( empty( $relative_path ) ) {
325 return false;
326 }
327
328 $upload_dir = wp_upload_dir();
329
330 return $upload_dir['basedir'] . '/' . $relative_path;
331 }
332 }
333