PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.5.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.5.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.5.0, at inc/pdf/receipt-generator.php

453 lines 17.8 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 if ( ! self::should_generate( $donation ) ) {
43 return false;
44 }
45
46 // Check if a cached PDF exists.
47 $existing_relative = $donation['receipt_pdf_url'] ?? '';
48
49 if ( ! empty( $existing_relative ) ) {
50 $existing_path = self::relative_to_path( Helper::get_string_value( $existing_relative ) );
51
52 if ( $existing_path && file_exists( $existing_path ) ) {
53 return $existing_path;
54 }
55 }
56
57 return self::generate( $donation_id );
58 }
59
60 /**
61 * Generate a PDF receipt for a donation.
62 *
63 * @param int $donation_id Donation ID.
64 * @return string|false File path on success, false on failure.
65 * @since 1.0.0
66 */
67 public static function generate( $donation_id ) {
68 if ( ! Pdf_Utils::check_if_library_exists() || ! Pdf_Utils::is_php_compatible() ) {
69 return false;
70 }
71
72 // Load the mPDF autoloader.
73 require_once Pdf_Utils::get_library_path() . '/vendor/autoload.php';
74
75 $donation = Donations::get( $donation_id );
76
77 if ( ! $donation ) {
78 return false;
79 }
80
81 if ( ! self::should_generate( $donation ) ) {
82 return false;
83 }
84
85 $donor_id = Helper::get_integer_value( $donation['donor_id'] ?? 0 );
86 $donor = $donor_id ? Donors::get( $donor_id ) : null;
87
88 $campaign_id = Helper::get_integer_value( $donation['campaign_id'] ?? 0 );
89 $campaign_title = $campaign_id ? (string) get_the_title( $campaign_id ) : '';
90
91 // Build the receipt HTML.
92 $html = self::build_receipt_html( $donation, $donor, $campaign_title );
93
94 /**
95 * Filter the receipt HTML before PDF generation.
96 *
97 * SECURITY NOTE: The returned HTML is passed directly to mPDF's
98 * WriteHTML(). Resource fetching is blocked at the mPDF level by the
99 * empty 'whitelistStreamWrappers' in self::get_mpdf_config(), so an
100 * `<img src>` cannot reach a remote host or a local file — but that is
101 * the only guarantee. Everything else about the returned markup is
102 * trusted, so only ever return escaped content.
103 *
104 * @param string $html Receipt HTML.
105 * @param array $donation Donation data.
106 * @param array|null $donor Donor data.
107 * @since 1.0.0
108 */
109 $html = apply_filters( 'suredonation_receipt_html', $html, $donation, $donor );
110
111 // Ensure receipts directory exists.
112 Pdf_Utils::ensure_receipts_dir();
113
114 $receipts_dir = Pdf_Utils::get_receipts_dir();
115 $default_filename = sprintf( 'suredonation-receipt-%d-%s.pdf', $donation_id, wp_generate_password( 8, false ) );
116
117 /**
118 * Filter the receipt PDF filename.
119 *
120 * The default filename carries a random suffix; the receipts directory
121 * is access-protected, but the suffix keeps individual filenames
122 * unguessable as defense in depth. The filtered value is passed through
123 * sanitize_file_name() and forced to a .pdf extension; an empty result
124 * falls back to the default.
125 *
126 * @param string $default_filename Generated filename.
127 * @param array<string, mixed> $donation Donation data.
128 * @param array<string, mixed>|null $donor Donor data.
129 * @since 1.5.0
130 */
131 $filename = apply_filters( 'suredonation_receipt_filename', $default_filename, $donation, $donor );
132 $filename = sanitize_file_name( Helper::get_string_value( $filename ) );
133
134 if ( '' === $filename ) {
135 $filename = $default_filename;
136 } elseif ( 'pdf' !== strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ) ) {
137 $filename .= '.pdf';
138 }
139
140 $filepath = $receipts_dir . '/' . $filename;
141
142 try {
143 $mpdf = new \Mpdf\Mpdf( self::get_mpdf_config( $donation, $donor ) );
144
145 /**
146 * Fires after the mPDF instance is created, before the receipt HTML is written.
147 *
148 * Allows instance-level configuration the constructor config cannot
149 * express — e.g. SetProtection() for password-protected receipts or
150 * SetTitle()/SetAuthor() document metadata.
151 *
152 * @param \Mpdf\Mpdf $mpdf mPDF instance.
153 * @param array<string, mixed> $donation Donation data.
154 * @param array<string, mixed>|null $donor Donor data.
155 * @since 1.5.0
156 */
157 do_action( 'suredonation_receipt_mpdf_instance', $mpdf, $donation, $donor );
158
159 $mpdf->WriteHTML( $html );
160 $mpdf->Output( $filepath, \Mpdf\Output\Destination::FILE );
161 } catch ( \Exception $e ) {
162 return false;
163 }
164
165 // Store the relative path in the donation record (portable across domain changes).
166 $upload_dir = wp_upload_dir();
167 $relative_path = str_replace( $upload_dir['basedir'] . '/', '', $filepath );
168 Donations::update( $donation_id, [ 'receipt_pdf_url' => $relative_path ] );
169
170 /**
171 * Fires after a receipt PDF has been generated and stored.
172 *
173 * @param int $donation_id Donation ID.
174 * @param string $filepath Absolute path to the generated PDF.
175 * @param array<string, mixed> $donation Donation data.
176 * @since 1.5.0
177 */
178 do_action( 'suredonation_receipt_generated', $donation_id, $filepath, $donation );
179
180 return $filepath;
181 }
182
183 /**
184 * Whether a receipt should be generated or served for a donation.
185 *
186 * @param array<string, mixed> $donation Donation data.
187 * @return bool
188 * @since 1.5.0
189 */
190 private static function should_generate( $donation ) {
191 /**
192 * Short-circuit filter to disable receipt generation for a donation.
193 *
194 * Return false to prevent generating a new receipt PDF and to stop a
195 * previously cached one from being served. Lets extensions disable
196 * receipts selectively — e.g. a per-form "disable PDF receipt"
197 * setting keyed on the donation's form_id.
198 *
199 * @param bool $should_generate Whether to generate/serve the receipt. Default true.
200 * @param array<string, mixed> $donation Donation data.
201 * @since 1.5.0
202 */
203 return (bool) apply_filters( 'suredonation_should_generate_receipt', true, $donation );
204 }
205
206 /**
207 * Delete a receipt PDF file by its stored uploads-relative path.
208 *
209 * Used by the personal-data eraser: the receipt is generated from the donor's
210 * name/email/address, so an erasure must remove the file from disk, not just
211 * the database columns.
212 *
213 * @since 1.2.0
214 * @param string $relative_path Relative path within the uploads directory.
215 * @return bool True when no file remains (deleted or never existed), false when it survived deletion.
216 */
217 public static function delete_receipt( $relative_path ) {
218 $filepath = self::relative_to_path( $relative_path );
219
220 if ( false === $filepath || ! file_exists( $filepath ) ) {
221 return true;
222 }
223
224 wp_delete_file( $filepath );
225
226 // Re-check with is_file() (not file_exists()) — wp_delete_file() has a
227 // filesystem side effect PHPStan can't see, so re-calling the already
228 // narrowed file_exists() reads as always-false to it.
229 clearstatcache( true, $filepath );
230
231 return ! is_file( $filepath );
232 }
233
234 /**
235 * Get the mPDF configuration.
236 *
237 * @param array<string, mixed> $donation Donation data.
238 * @param array<string, mixed>|null $donor Donor data.
239 * @return array<string,mixed>
240 * @since 1.0.0
241 */
242 private static function get_mpdf_config( $donation = [], $donor = null ) {
243 $config = [
244 'mode' => 'utf-8',
245 'format' => 'A4',
246 'orientation' => 'P',
247 'margin_left' => 15,
248 'margin_right' => 15,
249 'margin_top' => 15,
250 'margin_bottom' => 15,
251 'default_font' => 'dejavusans',
252 'tempDir' => Pdf_Utils::get_temp_dir(),
253 // mPDF resolves `<img src>` and CSS url() through stream wrappers,
254 // and its default whitelist is ['http', 'https', 'file'] — so out of
255 // the box a src can reach an internal host (SSRF) or read a local
256 // file into the PDF (LFI). The receipt HTML is server-templated with
257 // escaped fields, but the suredonation_receipt_html filter and the
258 // Pro receipt templates both put author-controlled HTML through
259 // WriteHTML(), and wp_kses_post() permits <img src="http(s)://…">.
260 //
261 // Emptying the whitelist is the actual control: Mpdf\File\
262 // StreamWrapperChecker then rejects every `scheme://` src before a
263 // fetch happens. Nothing legitimate needs one — the logo is
264 // embedded as a data: URI (no `://`, so the check never fires) and
265 // local font/temp paths are plain paths. A blocked <img> degrades to
266 // mPDF's own imageError() handling rather than failing the render.
267 'whitelistStreamWrappers' => [],
268 // Kept as defence in depth: if a filter callback ever re-whitelists
269 // http(s), requests still verify SSL. This is not what stops the
270 // fetch — the whitelist above is.
271 'curlAllowUnsafeSslRequests' => false,
272 ];
273
274 /**
275 * Filter the mPDF configuration used for receipt generation.
276 *
277 * SECURITY NOTE: 'whitelistStreamWrappers' is emptied deliberately.
278 * Restoring any entry lets HTML that reaches WriteHTML() fetch that
279 * scheme, which is SSRF for http(s) and LFI for file://. It is
280 * force-reset to [] after this filter runs, so callbacks cannot widen
281 * it; resolve trusted assets (e.g. a logo attachment) to a data: URI or
282 * a validated local path server-side instead.
283 *
284 * @param array<string, mixed> $config mPDF configuration.
285 * @param array<string, mixed> $donation Donation data.
286 * @param array<string, mixed>|null $donor Donor data.
287 * @since 1.5.0
288 */
289 $config = apply_filters( 'suredonation_receipt_mpdf_config', $config, $donation, $donor );
290 $config = is_array( $config ) ? $config : [];
291
292 // Enforced post-filter: an empty stream-wrapper whitelist is what gates
293 // LFI/SSRF in mPDF, so it stays empty regardless of what filter
294 // callbacks return.
295 $config['whitelistStreamWrappers'] = [];
296 $config['curlAllowUnsafeSslRequests'] = false;
297
298 return $config;
299 }
300
301 /**
302 * Build the receipt HTML template.
303 *
304 * @param array<string, mixed> $donation Donation data.
305 * @param array<string, mixed>|null $donor Donor data.
306 * @param string $campaign_title Campaign title.
307 * @return string HTML content.
308 * @since 1.0.0
309 */
310 private static function build_receipt_html( $donation, $donor, $campaign_title ) {
311 $site_name = esc_html( get_bloginfo( 'name' ) );
312 $site_url = esc_url( site_url() );
313
314 $donation_id = Helper::get_integer_value( $donation['id'] ?? 0 );
315 // Prefer the name captured on this specific donation — it is the correct
316 // identity for a tax receipt and is unaffected by the donor record being
317 // set-once. Fall back to the donor record only when the donation itself
318 // carries no name.
319 $donation_donor_name = Helper::get_string_value( $donation['donor_name'] ?? '' );
320 $donor_name = esc_html( '' !== $donation_donor_name ? $donation_donor_name : Helper::get_string_value( $donor['name'] ?? '' ) );
321 $donor_email = esc_html( Helper::get_string_value( $donor['email'] ?? '' ) );
322 $payment_status = esc_html( ucfirst( Helper::get_string_value( $donation['payment_status'] ?? '' ) ) );
323 $payment_method = esc_html( ucfirst( Helper::get_string_value( $donation['gateway'] ?? '' ) ) );
324 $transaction_id = esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) );
325 $currency = Helper::get_string_value( $donation['currency'] ?? 'USD' );
326 $total = Helper::get_float_value( $donation['amount'] ?? 0 );
327 $fees_covered = Helper::get_float_value( $donation['fees_covered'] ?? 0 );
328 $amount = $total - $fees_covered;
329 $date = Helper::get_string_value( $donation['created_at'] ?? '' );
330
331 if ( ! empty( $date ) ) {
332 $date_format = Helper::get_string_value( get_option( 'date_format' ) );
333 $timestamp = strtotime( $date );
334 $formatted_date = false !== $timestamp ? wp_date( $date_format, $timestamp ) : false;
335 $date = is_string( $formatted_date ) ? $formatted_date : $date;
336 }
337
338 $campaign_title = esc_html( $campaign_title );
339
340 // Format amounts.
341 $formatted_amount = self::format_currency( $amount, $currency );
342 $formatted_fees = self::format_currency( $fees_covered, $currency );
343 $formatted_total = self::format_currency( $total, $currency );
344
345 // Build transaction ID row.
346 $transaction_row = '';
347 if ( ! empty( $transaction_id ) ) {
348 $transaction_row = sprintf(
349 '<tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">%s</td>
350 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">%s</td></tr>',
351 esc_html__( 'Transaction ID', 'suredonation' ),
352 $transaction_id
353 );
354 }
355
356 // Build fees row.
357 $fees_row = '';
358 if ( $fees_covered > 0 ) {
359 $fees_row = sprintf(
360 '<tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">%s</td>
361 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">%s</td></tr>',
362 esc_html__( 'Fees Covered', 'suredonation' ),
363 $formatted_fees
364 );
365 }
366
367 return '
368 <div style="max-width:560px;margin:0 auto;font-family:DejaVu Sans,sans-serif;color:#111827;">
369 <div style="text-align:center;margin-bottom:24px;padding-bottom:20px;border-bottom:2px solid #e5e7eb;">
370 <h1 style="font-size:20px;margin:0 0 4px;color:#111827;">' . $site_name . '</h1>
371 <p style="color:#6b7280;font-size:13px;margin:0;">' . esc_html__( 'Donation Receipt', 'suredonation' ) . '</p>
372 </div>
373
374 <p style="color:#6b7280;font-size:12px;margin:0 0 16px;text-align:right;">'
375 . esc_html__( 'Receipt', 'suredonation' ) . ' #' . $donation_id . '</p>
376
377 <table style="width:100%;border-collapse:collapse;margin-bottom:20px;border:1px solid #e5e7eb;border-radius:6px;">
378 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;width:40%;">'
379 . esc_html__( 'Donor Name', 'suredonation' ) . '</td>
380 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $donor_name . '</td></tr>
381 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">'
382 . esc_html__( 'Donor Email', 'suredonation' ) . '</td>
383 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $donor_email . '</td></tr>
384 </table>
385
386 <table style="width:100%;border-collapse:collapse;margin-bottom:20px;border:1px solid #e5e7eb;border-radius:6px;">
387 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;width:40%;">'
388 . esc_html__( 'Campaign Name', 'suredonation' ) . '</td>
389 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $campaign_title . '</td></tr>
390 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">'
391 . esc_html__( 'Payment Status', 'suredonation' ) . '</td>
392 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $payment_status . '</td></tr>
393 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">'
394 . esc_html__( 'Payment Method', 'suredonation' ) . '</td>
395 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $payment_method . '</td></tr>
396 ' . $transaction_row . '
397 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">'
398 . esc_html__( 'Donation Amount', 'suredonation' ) . '</td>
399 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $formatted_amount . '</td></tr>
400 ' . $fees_row . '
401 <tr><td style="padding:10px 12px;background:#f9fafb;font-weight:bold;color:#111827;font-size:13px;">'
402 . esc_html__( 'Donation Total', 'suredonation' ) . '</td>
403 <td style="padding:10px 12px;background:#f9fafb;font-weight:bold;font-size:13px;color:#111827;">' . $formatted_total . '</td></tr>
404 </table>
405
406 <p style="color:#6b7280;font-size:12px;margin:0 0 4px;">'
407 . esc_html__( 'Date', 'suredonation' ) . ': ' . esc_html( $date ) . '</p>
408
409 <div style="margin-top:30px;padding-top:16px;border-top:1px solid #e5e7eb;text-align:center;">
410 <p style="color:#9ca3af;font-size:11px;margin:0;">'
411 . sprintf(
412 /* translators: 1: Site name, 2: Site URL. */
413 esc_html__( 'Generated by %1$s · %2$s', 'suredonation' ),
414 $site_name,
415 $site_url
416 ) . '</p>
417 </div>
418 </div>';
419 }
420
421 /**
422 * Format a monetary amount with currency symbol.
423 *
424 * @param float $amount Amount to format.
425 * @param string $currency Currency code.
426 * @return string Formatted amount.
427 * @since 1.0.0
428 */
429 private static function format_currency( $amount, $currency = 'USD' ) {
430 // Delegate to the single source of truth so the currency symbol,
431 // decimal handling and sign position match every other surface
432 // (this replaces a divergent local symbol map).
433 return Payment_Helper::format_amount( $amount, $currency );
434 }
435
436 /**
437 * Convert a relative path to an absolute file path.
438 *
439 * @param string $relative_path Relative path within the uploads directory.
440 * @return string|false Absolute file path or false.
441 * @since 1.0.0
442 */
443 private static function relative_to_path( $relative_path ) {
444 if ( empty( $relative_path ) ) {
445 return false;
446 }
447
448 $upload_dir = wp_upload_dir();
449
450 return $upload_dir['basedir'] . '/' . $relative_path;
451 }
452 }
453