PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.0
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 / import / charitable / csv-parser.php

csv-parser.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.0, at inc/import/charitable/csv-parser.php

552 lines 20.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * CSV import path for Charitable donation exports.
4 *
5 * Synchronous streaming parser for the CSV files Charitable's own
6 * Reports → Export tool produces. Charitable exports one row per
7 * donation × campaign (Donation ID legitimately repeats across rows of a
8 * multi-campaign donation), so rows are deduped on the donation-post +
9 * source-campaign pair — the same tokens the direct-DB importer stores —
10 * making DB-then-CSV re-imports skip cleanly.
11 *
12 * @package SureDonation
13 * @since 1.5.1
14 */
15
16 namespace SureDonation\Inc\Import\Charitable;
17
18 use SureDonation\Inc\Database\Tables\Donations;
19 use SureDonation\Inc\Traits\Get_Instance;
20
21 // Exit if accessed directly.
22 defined( 'ABSPATH' ) || exit;
23
24 /**
25 * Csv_Parser class.
26 *
27 * @since 1.5.1
28 */
29 class Csv_Parser {
30 use Get_Instance;
31 use Provenance_Dedupe;
32
33 /**
34 * Hard cap on rows per upload — protects against runaway memory/time.
35 *
36 * @since 1.5.1
37 */
38 public const MAX_ROWS = 100000;
39
40 /**
41 * Charitable donation-export header => canonical field map (headers are
42 * lowercased before lookup).
43 *
44 * @since 1.5.1
45 */
46 public const COLUMN_MAP = [
47 'donation id' => 'charitable_donation_id',
48 'campaign id' => 'campaign_id',
49 'campaign title' => 'campaign_title',
50 'first name' => 'first_name',
51 'last name' => 'last_name',
52 'email' => 'email',
53 'address' => 'address',
54 'address 2' => 'address_2',
55 'city' => 'city',
56 'state' => 'state',
57 'postcode' => 'postcode',
58 'country' => 'country',
59 'phone number' => 'phone',
60 'donation amount' => 'amount',
61 'date of donation' => 'date',
62 'time of donation' => 'time',
63 'donation status' => 'status_label',
64 'donation gateway' => 'gateway_label',
65 'made in test mode' => 'test_mode',
66 'contact consent' => 'contact_consent',
67 ];
68
69 /**
70 * Parse a Charitable CSV file and import its rows.
71 *
72 * @param string $file_path Absolute path to a readable CSV file.
73 * @return array{imported:int,skipped:int,errors:int,error_log:array<int,array{row:int,message:string}>,gateway_breakdown:array<string,int>}
74 * @since 1.5.1
75 */
76 public function parse_donations( $file_path ) {
77 $results = [
78 'imported' => 0,
79 'skipped' => 0,
80 'errors' => 0,
81 'error_log' => [],
82 'gateway_breakdown' => [],
83 ];
84
85 if ( ! is_string( $file_path ) || ! is_readable( $file_path ) ) {
86 ++$results['errors'];
87 $results['error_log'][] = [
88 'row' => 0,
89 'message' => __( 'CSV file is not readable.', 'suredonation' ),
90 ];
91 return $results;
92 }
93
94 $handle = fopen( $file_path, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming a user-uploaded CSV.
95 if ( false === $handle ) {
96 ++$results['errors'];
97 $results['error_log'][] = [
98 'row' => 0,
99 'message' => __( 'Could not open CSV file.', 'suredonation' ),
100 ];
101 return $results;
102 }
103
104 $header_row = fgetcsv( $handle );
105 if ( false === $header_row || empty( $header_row ) ) {
106 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
107 ++$results['errors'];
108 $results['error_log'][] = [
109 'row' => 0,
110 'message' => __( 'CSV is empty or missing header row.', 'suredonation' ),
111 ];
112 return $results;
113 }
114
115 $column_index = $this->build_column_index( $header_row );
116
117 // Dedupe is a per-row indexed lookup on the import_provenance column
118 // (Provenance_Dedupe) — no prior-import set to preload here.
119
120 // Engage email suppression for the duration of the import.
121 $suppressor = Email_Suppressor::get_instance();
122 $suppressor->activate();
123
124 $row_number = 1; // header was row 1.
125 $progress = [
126 'donor_map' => [],
127 // Charitable's export omits currency; fall back to the site's
128 // configured Charitable currency (USD when unknown).
129 'currency' => $this->get_charitable_currency(),
130 // A run id stamped on every row so CSV-imported donations carry
131 // provenance and can participate in rollback.
132 'import_id' => wp_generate_uuid4(),
133 ];
134
135 try {
136 while ( false !== ( $row = fgetcsv( $handle ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition -- Idiomatic CSV stream.
137 ++$row_number;
138 if ( $row_number - 1 > self::MAX_ROWS ) {
139 ++$results['errors'];
140 $results['error_log'][] = [
141 'row' => (int) $row_number,
142 'message' => sprintf(
143 /* translators: %d: row cap for a single CSV upload. */
144 __( 'CSV exceeds the per-upload row cap (%d). Split the export into smaller files.', 'suredonation' ),
145 (int) self::MAX_ROWS
146 ),
147 ];
148 break;
149 }
150 $this->process_row( $row, $column_index, $progress, $results, $row_number );
151 }
152 } finally {
153 $suppressor->deactivate();
154 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
155 }
156
157 return $results;
158 }
159
160 /**
161 * Build a map of canonical field => column index from the header row.
162 *
163 * @param array<int, string> $header_row Raw header row.
164 * @return array<string, int>
165 * @since 1.5.1
166 */
167 private function build_column_index( $header_row ) {
168 $index = [];
169 foreach ( $header_row as $col => $header ) {
170 $key = strtolower( trim( (string) $header ) );
171 if ( isset( self::COLUMN_MAP[ $key ] ) ) {
172 $index[ self::COLUMN_MAP[ $key ] ] = (int) $col;
173 }
174 }
175 return $index;
176 }
177
178 /**
179 * Import one CSV row.
180 *
181 * @param array<int, string|null> $row Raw CSV row.
182 * @param array<string, int> $column_index Field => column map.
183 * @param array<string, mixed> $progress Lightweight progress (donor cache).
184 * @param array{imported: int, skipped: int, errors: int, error_log: array<int, array{row: int, message: string}>, gateway_breakdown: array<string, int>} $results Results accumulator (by reference).
185 * @param int $row_number 1-based row number for error logs.
186 * @return void
187 * @since 1.5.1
188 */
189 private function process_row( $row, $column_index, &$progress, &$results, $row_number ) {
190 $field = static function ( $key ) use ( $row, $column_index ) {
191 if ( ! isset( $column_index[ $key ] ) ) {
192 return '';
193 }
194 $value = $row[ $column_index[ $key ] ] ?? '';
195 return is_scalar( $value ) ? trim( (string) $value ) : '';
196 };
197
198 $email = sanitize_email( $field( 'email' ) );
199 if ( '' === $email || ! is_email( $email ) ) {
200 ++$results['errors'];
201 $this->log_row_error( $results, $row_number, __( 'Row has no valid donor email.', 'suredonation' ) );
202 return;
203 }
204
205 $amount = $this->parse_amount( $field( 'amount' ) );
206 if ( $amount <= 0 ) {
207 ++$results['errors'];
208 $this->log_row_error( $results, $row_number, __( 'Row has no positive donation amount.', 'suredonation' ) );
209 return;
210 }
211
212 $donation_post_id = absint( $field( 'charitable_donation_id' ) );
213 $source_campaign_id = absint( $field( 'campaign_id' ) );
214 $campaign_title = sanitize_text_field( $field( 'campaign_title' ) );
215
216 // A hand-edited export can leave the Campaign ID cell blank. The DB import
217 // path always carries the numeric id, so recover it from the title via the
218 // campaign the campaign phase imported — otherwise the two paths key the same
219 // gift differently (numeric id vs title hash) and it imports twice. Falls back
220 // to the title hash only when no imported campaign matches the title.
221 if ( 0 === $source_campaign_id && '' !== $campaign_title ) {
222 $source_campaign_id = $this->resolve_source_campaign_id_from_title( $campaign_title );
223 }
224
225 // A row with no Donation ID cannot be deduped or rolled back (both key on
226 // import_source_id > 0), so it would re-import as a duplicate on every
227 // upload and escape rollback. Reject it rather than import an orphan.
228 if ( $donation_post_id <= 0 ) {
229 ++$results['errors'];
230 $this->log_row_error( $results, $row_number, __( 'Row has no Donation ID — cannot be de-duplicated or rolled back; skipped.', 'suredonation' ) );
231 return;
232 }
233
234 // Dedupe on the donation-post + campaign pair (Charitable exports one row
235 // per donation × campaign, so Donation ID alone is not unique). When the
236 // export leaves the Campaign ID cell blank, the campaign title keeps the
237 // per-campaign rows of a multi-campaign donation distinct instead of
238 // collapsing to "<post>:0" (which would silently drop the 2nd+ rows).
239 if ( $this->provenance_seen( $donation_post_id, $source_campaign_id, $campaign_title ) ) {
240 ++$results['skipped'];
241 return;
242 }
243
244 // Synthesize the donor snapshot shape the shared Donor_Mapper expects.
245 $snapshot = [
246 'first_name' => sanitize_text_field( $field( 'first_name' ) ),
247 'last_name' => sanitize_text_field( $field( 'last_name' ) ),
248 'email' => $email,
249 'address' => sanitize_text_field( $field( 'address' ) ),
250 'address_2' => sanitize_text_field( $field( 'address_2' ) ),
251 'city' => sanitize_text_field( $field( 'city' ) ),
252 'state' => sanitize_text_field( $field( 'state' ) ),
253 'postcode' => sanitize_text_field( $field( 'postcode' ) ),
254 'country' => sanitize_text_field( $field( 'country' ) ),
255 'phone' => sanitize_text_field( $field( 'phone' ) ),
256 ];
257
258 $donation_meta = [
259 'contact_consent' => $this->truthy( $field( 'contact_consent' ) ) ? '1' : '',
260 ];
261
262 $donor_id = Donor_Mapper::get_instance()->get_or_create_for_donation( $snapshot, 0, $donation_meta, $progress );
263 if ( $donor_id <= 0 ) {
264 ++$results['errors'];
265 $this->log_row_error( $results, $row_number, __( 'Could not resolve a SureDonation donor for this row.', 'suredonation' ) );
266 return;
267 }
268
269 $gateway_label = $field( 'gateway_label' );
270 $gateway = Status_Map::map_gateway_label( $gateway_label );
271
272 $created_at = trim( $field( 'date' ) . ' ' . $field( 'time' ) );
273 $created_ts = '' !== $created_at ? strtotime( $created_at ) : false;
274 $created_at = false !== $created_ts ? gmdate( 'Y-m-d H:i:s', $created_ts ) : current_time( 'mysql', true );
275
276 $charitable_block = array_filter(
277 [
278 'donation_post_id' => $donation_post_id,
279 'source_campaign_id' => $source_campaign_id,
280 'campaign_title' => $campaign_title,
281 'gateway_raw' => sanitize_text_field( $gateway_label ),
282 'import_id' => isset( $progress['import_id'] ) ? (string) $progress['import_id'] : '',
283 'contact_consent' => $this->truthy( $field( 'contact_consent' ) ),
284 'csv_row' => (int) $row_number,
285 ]
286 );
287 $charitable_block['gateway_live'] = Status_Map::is_gateway_live( $gateway );
288
289 $payment_status = Status_Map::map_status_label( $field( 'status_label' ) );
290
291 $donation_id = Donations::add(
292 [
293 'campaign_id' => 0,
294 'donor_id' => $donor_id,
295 'form_id' => 0,
296 'amount' => (string) $amount,
297 'currency' => isset( $progress['currency'] ) ? (string) $progress['currency'] : 'USD',
298 'transaction_id' => '',
299 'customer_id' => '',
300 'gateway' => $gateway,
301 'payment_status' => $payment_status,
302 'payment_mode' => $this->truthy( $field( 'test_mode' ) ) ? 'test' : 'live',
303 'donor_name' => trim( $snapshot['first_name'] . ' ' . $snapshot['last_name'] ),
304 'donor_email' => $email,
305 'donation_type' => 'one-time',
306 'donation_data' => [ 'charitable' => $charitable_block ],
307 'created_at' => $created_at,
308 'import_source_id' => $donation_post_id,
309 'import_source' => 'charitable',
310 'import_provenance' => $this->provenance_key( $donation_post_id, $source_campaign_id, $campaign_title ),
311 ]
312 );
313
314 if ( ! $donation_id ) {
315 ++$results['errors'];
316 $this->log_row_error( $results, $row_number, __( 'Database insert failed for row.', 'suredonation' ) );
317 return;
318 }
319
320 // Keep donor rollups correct (mirrors the direct-DB path — without this
321 // CSV-imported donors would report total_donated = 0).
322 Donation_Mapper::get_instance()->update_donor_aggregates( $donor_id, $amount, $created_at, $payment_status );
323
324 // Record the pair so later rows in this same file (and re-uploads) skip.
325 $this->mark_provenance_seen( $donation_post_id, $source_campaign_id, $campaign_title );
326
327 ++$results['imported'];
328 if ( ! isset( $results['gateway_breakdown'][ $gateway ] ) ) {
329 $results['gateway_breakdown'][ $gateway ] = 0;
330 }
331 ++$results['gateway_breakdown'][ $gateway ];
332 }
333
334 /**
335 * Parse a donation amount from a locale-formatted CSV cell.
336 *
337 * Charitable renders the export amount using the decimal/thousands
338 * separators configured in charitable_settings, so those are read and used
339 * to parse deterministically — strip the thousands separator, normalise the
340 * decimal separator to a dot. This removes the ambiguity a positional
341 * heuristic has with dot-grouped integers (e.g. "1.234.567", which a
342 * lone-dot-is-decimal heuristic would truncate to 1.234). When the site has
343 * no configured separators (Charitable never set them), fall back to the
344 * heuristic: the right-most of `.`/`,` is the decimal, the other groups
345 * thousands; a lone comma is a decimal only when it looks like one.
346 *
347 * @param string $raw Raw amount cell.
348 * @return float Non-negative amount (0.0 when unparseable).
349 * @since 1.5.1
350 */
351 private function parse_amount( $raw ) {
352 $raw = preg_replace( '/[^0-9.,\-]/', '', (string) $raw );
353 if ( null === $raw || '' === $raw ) {
354 return 0.0;
355 }
356
357 $sep = $this->get_charitable_separators();
358 if ( '' !== $sep['decimal'] ) {
359 // Deterministic: drop the (possibly empty) thousands separator, then
360 // normalise the decimal separator to a dot.
361 if ( '' !== $sep['thousands'] ) {
362 $raw = str_replace( $sep['thousands'], '', $raw );
363 }
364 if ( '.' !== $sep['decimal'] ) {
365 $raw = str_replace( $sep['decimal'], '.', $raw );
366 }
367 return (float) $raw;
368 }
369
370 // Fallback heuristic (no configured separators).
371 $has_dot = false !== strpos( $raw, '.' );
372 $has_comma = false !== strpos( $raw, ',' );
373
374 if ( $has_dot && $has_comma ) {
375 if ( strrpos( $raw, ',' ) > strrpos( $raw, '.' ) ) {
376 $raw = str_replace( '.', '', $raw ); // Dots group thousands.
377 $raw = str_replace( ',', '.', $raw ); // Comma is the decimal point.
378 } else {
379 $raw = str_replace( ',', '', $raw ); // Commas group thousands.
380 }
381 } elseif ( $has_comma ) {
382 $raw = preg_match( '/,\d{1,2}$/', $raw )
383 ? str_replace( ',', '.', $raw ) // Decimal comma (e.g. 1234,56).
384 : str_replace( ',', '', $raw ); // Thousands grouping (e.g. 1,234).
385 }
386
387 return (float) $raw;
388 }
389
390 /**
391 * The site's configured Charitable decimal/thousands separators, read from
392 * the same charitable_settings option as the currency. Each is a single
393 * character; an empty 'decimal' means Charitable has no configured value and
394 * parse_amount() should fall back to its heuristic.
395 *
396 * @return array{decimal:string,thousands:string}
397 * @since 1.5.1
398 */
399 private function get_charitable_separators() {
400 $settings = get_option( 'charitable_settings', [] );
401 if ( ! is_array( $settings ) ) {
402 return [
403 'decimal' => '',
404 'thousands' => '',
405 ];
406 }
407
408 $decimal = isset( $settings['decimal_separator'] ) && is_scalar( $settings['decimal_separator'] ) ? (string) $settings['decimal_separator'] : '';
409 $thousands = isset( $settings['thousands_separator'] ) && is_scalar( $settings['thousands_separator'] ) ? (string) $settings['thousands_separator'] : '';
410
411 // Only accept single-character separators, and never accept a decimal
412 // that equals the thousands separator (would be ambiguous).
413 $decimal = 1 === strlen( $decimal ) ? $decimal : '';
414 $thousands = 1 === strlen( $thousands ) ? $thousands : '';
415 if ( '' !== $decimal && $decimal === $thousands ) {
416 $decimal = '';
417 }
418
419 return [
420 'decimal' => $decimal,
421 'thousands' => $thousands,
422 ];
423 }
424
425 /**
426 * The site's configured Charitable currency, used as the default for CSV
427 * rows (Charitable's export has no currency column). USD when unknown.
428 *
429 * @return string Three-letter currency code.
430 * @since 1.5.1
431 */
432 private function get_charitable_currency() {
433 $settings = get_option( 'charitable_settings', [] );
434 $currency = is_array( $settings ) && isset( $settings['currency'] ) ? (string) $settings['currency'] : '';
435 $currency = strtoupper( sanitize_text_field( $currency ) );
436
437 return 1 === preg_match( '/^[A-Z]{3}$/', $currency ) ? $currency : 'USD';
438 }
439
440 /**
441 * Loose truthiness for CSV cells ("1", "yes", "true", "y").
442 *
443 * @param string $value Raw cell value.
444 * @return bool
445 * @since 1.5.1
446 */
447 private function truthy( $value ) {
448 return in_array( strtolower( trim( (string) $value ) ), [ '1', 'yes', 'true', 'y' ], true );
449 }
450
451 /**
452 * Append a row error, capping the log at 50 entries.
453 *
454 * @param array{imported: int, skipped: int, errors: int, error_log: array<int, array{row: int, message: string}>, gateway_breakdown: array<string, int>} $results Results accumulator (by reference).
455 * @param int $row_number Row number.
456 * @param string $message Error message.
457 * @return void
458 * @since 1.5.1
459 */
460 private function log_row_error( &$results, $row_number, $message ) {
461 if ( count( $results['error_log'] ) >= 50 ) {
462 return;
463 }
464 $results['error_log'][] = [
465 'row' => (int) $row_number,
466 'message' => (string) $message,
467 ];
468 }
469
470 /**
471 * Charitable campaign title => Charitable campaign id, from the SD campaigns the
472 * campaign phase created (which store the source id in post meta). Lets a CSV row
473 * whose Campaign ID cell is blank key on the same numeric id the DB path wrote,
474 * instead of a title hash the two paths can never share. Memoized per import.
475 *
476 * @var array<string,int>|null
477 * @since 1.5.1
478 */
479 private $campaign_title_to_source_id = null;
480
481 /**
482 * Resolve a Charitable campaign title to its Charitable campaign id.
483 *
484 * @param string $title Campaign title from the CSV row.
485 * @return int Charitable campaign id, or 0 when no single imported campaign matches.
486 * @since 1.5.1
487 */
488 private function resolve_source_campaign_id_from_title( $title ) {
489 $title = strtolower( trim( (string) $title ) );
490
491 if ( '' === $title ) {
492 return 0;
493 }
494
495 if ( null === $this->campaign_title_to_source_id ) {
496 $this->campaign_title_to_source_id = $this->build_campaign_title_map();
497 }
498
499 return isset( $this->campaign_title_to_source_id[ $title ] ) ? $this->campaign_title_to_source_id[ $title ] : 0;
500 }
501
502 /**
503 * Build the campaign-title => Charitable-source-id map from imported SD campaigns.
504 *
505 * A title that maps to more than one distinct source id is ambiguous and left out,
506 * so those rows fall back to the title hash rather than resolve to the wrong id.
507 *
508 * @return array<string,int>
509 * @since 1.5.1
510 */
511 private function build_campaign_title_map() {
512 global $wpdb;
513
514 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Migration scope; one lookup per import.
515 $rows = $wpdb->get_results(
516 $wpdb->prepare(
517 "SELECT p.post_title AS title, pm.meta_value AS source_id FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID WHERE pm.meta_key = %s AND pm.meta_value > 0",
518 Campaign_Mapper::META_SOURCE_ID
519 ),
520 ARRAY_A
521 );
522
523 if ( ! is_array( $rows ) ) {
524 return [];
525 }
526
527 $map = [];
528 $ambiguous = [];
529
530 foreach ( $rows as $row ) {
531 $title = strtolower( trim( (string) ( $row['title'] ?? '' ) ) );
532 $sid = absint( $row['source_id'] ?? 0 );
533
534 if ( '' === $title || $sid <= 0 ) {
535 continue;
536 }
537
538 if ( isset( $map[ $title ] ) && $map[ $title ] !== $sid ) {
539 $ambiguous[ $title ] = true;
540 } elseif ( ! isset( $map[ $title ] ) ) {
541 $map[ $title ] = $sid;
542 }
543 }
544
545 foreach ( array_keys( $ambiguous ) as $ambiguous_title ) {
546 unset( $map[ $ambiguous_title ] );
547 }
548
549 return $map;
550 }
551 }
552