PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
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 / import-export / import / donations-import-mapper.php

donations-import-mapper.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.1, at inc/import-export/import/donations-import-mapper.php

508 lines 18.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Donations import mapper.
4 *
5 * Imports one batch of donation rows from a SureDonation-exported CSV: matches
6 * or creates the donor (by email), resolves campaign/form, dedups (source-id
7 * primary, content fallback), and inserts with import_source='suredonation'
8 * (which suppresses the suredonation_donation_created action so no automations
9 * or webhooks fire). Receipt emails are suppressed for the batch, and donor
10 * aggregates are updated date-aware.
11 *
12 * @package SureDonation
13 * @since 1.3.0
14 */
15
16 namespace SureDonation\Inc\Import_Export\Import;
17
18 use SureDonation\Inc\Database\Tables\Donations;
19 use SureDonation\Inc\Database\Tables\Donors;
20 use SureDonation\Inc\Helper;
21 use SureDonation\Inc\Import_Export\Csv_Exporter;
22 use SureDonation\Inc\Import\Givewp\Email_Suppressor;
23 use SureDonation\Inc\Post_Types\Donation_Form;
24 use SureDonation\Inc\Traits\Get_Instance;
25
26 // Exit if accessed directly.
27 if ( ! defined( 'ABSPATH' ) ) {
28 exit;
29 }
30
31 /**
32 * Donations import mapper.
33 *
34 * @phpstan-type ImportResult array{imported:int, skipped:int, errors:int, donors_created:int, donors_matched:int, error_log:array<int, string>}
35 *
36 * @since 1.3.0
37 */
38 class Donations_Import_Mapper {
39
40 use Get_Instance;
41
42 /**
43 * Donation statuses that contribute to donor revenue aggregates.
44 *
45 * @var array<int, string>
46 * @since 1.3.0
47 */
48 const REVENUE_STATUSES = [ 'completed', 'partially_refunded' ];
49
50 /**
51 * Process one batch of donation rows.
52 *
53 * @param array<string, mixed> $progress Session progress (by reference).
54 * @param int $offset Data-row offset.
55 * @return int Number of source rows fetched this batch.
56 * @since 1.3.0
57 */
58 public function process_batch( array &$progress, $offset ) {
59 $token = Helper::get_string_value( $progress['token'] ?? '' );
60 $mapping = is_array( $progress['mapping'] ?? null ) ? $progress['mapping'] : [];
61 $options = is_array( $progress['options'] ?? null ) ? $progress['options'] : [];
62 $dry_run = ! empty( $options['dry_run'] );
63
64 // Offset 0 marks the start of the phase, so reset the byte cursor to the
65 // top of the file; otherwise resume from the stored byte position.
66 $byte_offset = ( 0 === (int) $offset ) ? 0 : Helper::get_integer_value( $progress['byte_offset'] ?? 0 );
67 $rows = Csv_File::read_batch( $token, $byte_offset, Import_Runner::BATCH_SIZE );
68 $progress['byte_offset'] = $byte_offset;
69 if ( empty( $rows ) ) {
70 return 0;
71 }
72
73 $suppressor = Email_Suppressor::get_instance();
74 if ( ! $dry_run ) {
75 $suppressor->activate();
76 }
77
78 try {
79 foreach ( $rows as $row ) {
80 $this->process_row( $row, $mapping, $progress, $dry_run );
81 }
82 } finally {
83 if ( ! $dry_run ) {
84 $suppressor->deactivate();
85 }
86 }
87
88 return count( $rows );
89 }
90
91 /**
92 * Import a single donation row.
93 *
94 * @param array<int, string> $row Raw CSV cells.
95 * @param array<int|string, mixed> $mapping Header index => field.
96 * @param array<string, mixed> $progress Session progress (by reference).
97 * @param bool $dry_run Whether to write.
98 * @return void
99 * @since 1.3.0
100 */
101 private function process_row( $row, $mapping, &$progress, $dry_run ) {
102 /** @var array<string, ImportResult> $results */
103 $results = &$progress['results'];
104 $result = &$results['donations'];
105 $options = is_array( $progress['options'] ?? null ) ? $progress['options'] : [];
106 $data = Column_Map::apply_row( $row, $mapping );
107
108 $email = sanitize_email( Helper::get_string_value( $data['donor_email'] ?? '' ) );
109 $amount = $this->normalize_amount( $data['amount'] ?? '' );
110
111 // Reject a missing/invalid email or a non-positive amount. A blank or
112 // non-numeric amount normalizes to 0, so the <= 0 check also rejects
113 // "abc" and "0" (which would otherwise import a meaningless $0 donation).
114 if ( '' === $email || ! is_email( $email ) || (float) $amount <= 0 ) {
115 ++$result['errors'];
116 $this->log_error( $result, __( 'Row skipped: missing/invalid email or non-positive amount.', 'suredonation' ) );
117 return;
118 }
119
120 $source_id = isset( $data['import_source_id'] ) ? absint( $data['import_source_id'] ) : 0;
121 // Every imported donation goes into the campaign chosen at import time;
122 // the file's own campaign column is not used for linking.
123 $campaign_id = Helper::get_integer_value( $options['campaign_id'] ?? 0 );
124 $gateway = sanitize_text_field( Helper::get_string_value( $data['gateway'] ?? '' ) );
125 $mode = $this->normalize_mode( $data['payment_mode'] ?? '' );
126 $status = $this->normalize_status( $data['payment_status'] ?? '' );
127 $date = $this->normalize_date( $data['donation_date'] ?? '' );
128
129 if ( $this->is_duplicate( $source_id, $email, $amount, $date, $campaign_id, $gateway, $mode ) ) {
130 ++$result['skipped'];
131 return;
132 }
133
134 $donor_id = $this->resolve_donor( $email, $data, $progress, $dry_run, $result );
135
136 if ( $dry_run ) {
137 ++$result['imported'];
138 return;
139 }
140
141 $donation = [
142 'campaign_id' => $campaign_id,
143 'donor_id' => $donor_id,
144 'form_id' => $this->resolve_form( $data ),
145 'amount' => $amount,
146 'fees_covered' => $this->normalize_amount( $data['fees_covered'] ?? 0 ),
147 'refunded_amount' => $this->normalize_amount( $data['refunded_amount'] ?? 0 ),
148 'currency' => $this->fallback( sanitize_text_field( Helper::get_string_value( $data['currency'] ?? '' ) ), 'USD' ),
149 'transaction_id' => sanitize_text_field( Csv_Exporter::unescape_cell( Helper::get_string_value( $data['transaction_id'] ?? '' ) ) ),
150 'gateway' => $this->fallback( $gateway, 'offline' ),
151 'payment_status' => $status,
152 'payment_mode' => $mode,
153 'donor_name' => sanitize_text_field( Csv_Exporter::unescape_cell( Helper::get_string_value( $data['donor_name'] ?? '' ) ) ),
154 'donor_email' => $email,
155 'donor_phone' => sanitize_text_field( Csv_Exporter::unescape_cell( Helper::get_string_value( $data['donor_phone'] ?? '' ) ) ),
156 'is_anonymous' => $this->to_bool( $data['is_anonymous'] ?? '' ),
157 'donation_type' => 'one-time',
158 'donor_comment' => sanitize_textarea_field( Csv_Exporter::unescape_cell( Helper::get_string_value( $data['donor_comment'] ?? '' ) ) ),
159 'donor_comment_status' => $this->to_comment_status( $data['donor_comment_status'] ?? '' ),
160 'ip_address' => sanitize_text_field( Helper::get_string_value( $data['ip_address'] ?? '' ) ),
161 'import_source' => 'suredonation',
162 'import_source_id' => $source_id,
163 ];
164
165 if ( '' !== $date ) {
166 $donation['created_at'] = $date;
167 }
168
169 /**
170 * Filter the donation row before it is inserted. Pro uses this to add
171 * subscription fields (subscription_id, subscription_status,
172 * donation_type) from the mapped CSV row.
173 *
174 * @param array<string, mixed> $donation Donation data to insert.
175 * @param array<string, string> $data Mapped CSV row fields.
176 */
177 $filtered = apply_filters( 'suredonation_import_donation_row', $donation, $data );
178 if ( is_array( $filtered ) ) {
179 $donation = $filtered;
180 }
181
182 $donation_id = Donations::add( $donation );
183
184 if ( $donation_id ) {
185 $donation_id = (int) $donation_id;
186 ++$result['imported'];
187 $this->update_donor_aggregates( $donor_id, $amount, $status, $date );
188 Import_Runner::track_created( $progress, 'donations', $donation_id );
189 Import_Runner::track_id_map( $progress, $source_id, $donation_id );
190
191 /**
192 * Fires after an imported donation is inserted. Pro uses this to
193 * track created rows (for rollback) and old→new id mapping (to
194 * relink recurring renewals on completion).
195 *
196 * @param int $donation_id New donation id.
197 * @param array<string, string> $data Mapped CSV row fields.
198 * @param array<string, mixed> $donation Inserted donation data.
199 */
200 do_action( 'suredonation_import_donation_inserted', $donation_id, $data, $donation );
201
202 // Preserve per-form custom fields (columns beyond the standard
203 // export set) under donation_data['fields'] so the round-trip is
204 // lossless.
205 $headers = is_array( $options['headers'] ?? null ) ? $options['headers'] : [];
206 $custom = Column_Map::extract_custom_fields( $headers, $row );
207 if ( ! empty( $custom ) ) {
208 Donations::set_submitted_fields( $donation_id, $custom );
209 }
210 } else {
211 ++$result['errors'];
212 $this->log_error( $result, __( 'Row skipped: could not insert donation.', 'suredonation' ) );
213 }
214 }
215
216 /**
217 * Resolve the form id for a row (by numeric id, verified as a donation form).
218 *
219 * @param array<string, string> $data Row fields.
220 * @return int Form id, or 0.
221 * @since 1.3.0
222 */
223 private function resolve_form( $data ) {
224 $value = trim( Helper::get_string_value( $data['form'] ?? '' ) );
225 if ( '' === $value || ! is_numeric( $value ) ) {
226 return 0;
227 }
228 $post = get_post( absint( $value ) );
229 return ( $post instanceof \WP_Post && Donation_Form::POST_TYPE === $post->post_type ) ? (int) $post->ID : 0;
230 }
231
232 /**
233 * Match or create the donor for a row (by email), cached per session.
234 *
235 * Uses Donors::add() (never get_or_create) so no WP user is auto-created;
236 * an existing WP user is linked by email only.
237 *
238 * @param string $email Donor email.
239 * @param array<string, string> $data Row fields.
240 * @param array<string, mixed> $progress Session (by reference).
241 * @param bool $dry_run Whether to write.
242 * @param ImportResult $result Phase result counters (by reference).
243 * @return int Donor id, or 0.
244 * @since 1.3.0
245 */
246 private function resolve_donor( $email, $data, &$progress, $dry_run, &$result ) {
247 $map = is_array( $progress['donor_map'] ?? null ) ? $progress['donor_map'] : [];
248 if ( isset( $map[ $email ] ) ) {
249 return Helper::get_integer_value( $map[ $email ] );
250 }
251
252 $existing = Donors::get_by_email( $email );
253 if ( is_array( $existing ) && ! empty( $existing['id'] ) ) {
254 $id = Helper::get_integer_value( $existing['id'] );
255 $map[ $email ] = $id;
256 $progress['donor_map'] = $map;
257 ++$result['donors_matched'];
258 return $id;
259 }
260
261 ++$result['donors_created'];
262
263 if ( $dry_run ) {
264 $map[ $email ] = 0;
265 $progress['donor_map'] = $map;
266 return 0;
267 }
268
269 $name = trim( Helper::get_string_value( $data['donor_name'] ?? '' ) );
270 if ( '' === $name ) {
271 $name = trim( Helper::get_string_value( $data['first_name'] ?? '' ) . ' ' . Helper::get_string_value( $data['last_name'] ?? '' ) );
272 }
273
274 $user = get_user_by( 'email', $email );
275
276 $id = Donors::add(
277 [
278 'email' => $email,
279 'name' => sanitize_text_field( $name ),
280 'phone' => sanitize_text_field( Helper::get_string_value( $data['donor_phone'] ?? '' ) ),
281 'company' => sanitize_text_field( Helper::get_string_value( $data['company'] ?? '' ) ),
282 'address' => sanitize_textarea_field( Helper::get_string_value( $data['address'] ?? '' ) ),
283 'donor_status' => 'active',
284 'user_id' => $user instanceof \WP_User ? (int) $user->ID : null,
285 'import_source' => 'suredonation',
286 'import_source_id' => 0,
287 ]
288 );
289
290 $id = $id ? (int) $id : 0;
291 if ( $id > 0 ) {
292 Import_Runner::track_created( $progress, 'donors', $id );
293
294 /**
295 * Fires after an imported donor is created. Pro uses this to track
296 * created donors for rollback.
297 *
298 * @param int $donor_id New donor id.
299 * @param string $email Donor email.
300 */
301 do_action( 'suredonation_import_donor_inserted', $id, $email );
302 }
303 $map[ $email ] = $id;
304 $progress['donor_map'] = $map;
305 return $id;
306 }
307
308 /**
309 * Detect a duplicate donation: source-id first, then content fallback.
310 *
311 * @param int $source_id Original donation id from the CSV (0 if absent).
312 * @param string $email Donor email.
313 * @param string $amount Normalized amount.
314 * @param string $date Normalized created_at.
315 * @param int $campaign_id Campaign id.
316 * @param string $gateway Gateway.
317 * @param string $mode Payment mode.
318 * @return bool True if a matching donation already exists.
319 * @since 1.3.0
320 */
321 private function is_duplicate( $source_id, $email, $amount, $date, $campaign_id, $gateway, $mode ) {
322 global $wpdb;
323 $table = Donations::get_instance()->get_tablename();
324
325 if ( $source_id > 0 ) {
326 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Dedup lookup against live data.
327 $found = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE import_source = %s AND import_source_id = %d LIMIT 1', $table, 'suredonation', $source_id ) );
328 if ( ! empty( $found ) ) {
329 return true;
330 }
331 }
332
333 // Content fallback. Include created_at when the row carries a date; when
334 // it does not (a hand-made CSV missing both the source id and the date),
335 // match on the remaining fields so a re-run still dedups instead of
336 // inserting the row again every time.
337 $gw = $this->fallback( $gateway, 'offline' );
338 if ( '' !== $date ) {
339 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Dedup lookup against live data.
340 $found = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE donor_email = %s AND amount = %s AND created_at = %s AND campaign_id = %d AND gateway = %s AND payment_mode = %s LIMIT 1', $table, $email, $amount, $date, $campaign_id, $gw, $mode ) );
341 } else {
342 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Dedup lookup against live data.
343 $found = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE donor_email = %s AND amount = %s AND campaign_id = %d AND gateway = %s AND payment_mode = %s LIMIT 1', $table, $email, $amount, $campaign_id, $gw, $mode ) );
344 }
345
346 return ! empty( $found );
347 }
348
349 /**
350 * Update a donor's rolling aggregates from an imported donation, date-aware.
351 *
352 * @param int $donor_id Donor id.
353 * @param string $amount Normalized amount.
354 * @param string $status Payment status.
355 * @param string $date Donation date (mysql), or '' for now.
356 * @return void
357 * @since 1.3.0
358 */
359 private function update_donor_aggregates( $donor_id, $amount, $status, $date ) {
360 if ( $donor_id <= 0 ) {
361 return;
362 }
363 $donor = Donors::get( $donor_id );
364 if ( ! is_array( $donor ) ) {
365 return;
366 }
367
368 $donation_date = '' !== $date ? $date : current_time( 'mysql' );
369 $update = [];
370
371 $first = Helper::get_string_value( $donor['first_donation_date'] ?? '' );
372 $last = Helper::get_string_value( $donor['last_donation_date'] ?? '' );
373 if ( '' === $first || $donation_date < $first ) {
374 $update['first_donation_date'] = $donation_date;
375 }
376 if ( '' === $last || $donation_date > $last ) {
377 $update['last_donation_date'] = $donation_date;
378 }
379
380 if ( in_array( $status, self::REVENUE_STATUSES, true ) ) {
381 $amount_f = (float) $amount;
382 $update['total_donated'] = number_format( (float) ( $donor['total_donated'] ?? 0 ) + $amount_f, 8, '.', '' );
383 $update['donation_count'] = (int) ( $donor['donation_count'] ?? 0 ) + 1;
384 if ( $amount_f > (float) ( $donor['largest_donation'] ?? 0 ) ) {
385 $update['largest_donation'] = number_format( $amount_f, 8, '.', '' );
386 }
387 }
388
389 if ( ! empty( $update ) ) {
390 Donors::update( $donor_id, $update );
391 }
392 }
393
394 /**
395 * Normalize an amount to a plain decimal string.
396 *
397 * @param mixed $value Raw amount.
398 * @return string Decimal string.
399 * @since 1.3.0
400 */
401 private function normalize_amount( $value ) {
402 $clean = preg_replace( '/[^0-9.\-]/', '', Helper::get_string_value( $value ) );
403 return number_format( (float) $clean, 8, '.', '' );
404 }
405
406 /**
407 * Normalize the payment mode to 'live' or 'test' (default test).
408 *
409 * @param mixed $value Raw mode.
410 * @return string 'live' or 'test'.
411 * @since 1.3.0
412 */
413 private function normalize_mode( $value ) {
414 return 'live' === strtolower( trim( Helper::get_string_value( $value ) ) ) ? 'live' : 'test';
415 }
416
417 /**
418 * Normalize the payment status to a valid enum (default 'pending').
419 *
420 * @param mixed $value Raw status.
421 * @return string Valid payment status.
422 * @since 1.3.0
423 */
424 private function normalize_status( $value ) {
425 $status = strtolower( trim( Helper::get_string_value( $value ) ) );
426 $valid = Donations::get_valid_statuses();
427 return in_array( $status, $valid, true ) ? $status : 'pending';
428 }
429
430 /**
431 * Normalize a date to `Y-m-d H:i:s`, or '' when unparseable.
432 *
433 * @param mixed $value Raw date.
434 * @return string Formatted date or empty string.
435 * @since 1.3.0
436 */
437 private function normalize_date( $value ) {
438 $raw = trim( Helper::get_string_value( $value ) );
439 if ( '' === $raw ) {
440 return '';
441 }
442 $ts = strtotime( $raw );
443 return false === $ts ? '' : gmdate( 'Y-m-d H:i:s', $ts );
444 }
445
446 /**
447 * Coerce a truthy CSV value to a bool int.
448 *
449 * @param mixed $value Raw value.
450 * @return int 1 or 0.
451 * @since 1.3.0
452 */
453 private function to_bool( $value ) {
454 $v = strtolower( trim( Helper::get_string_value( $value ) ) );
455 return in_array( $v, [ '1', 'yes', 'true', 'y' ], true ) ? 1 : 0;
456 }
457
458 /**
459 * Normalize an imported donor-comment moderation status.
460 *
461 * Whitelisted against the column's own valid set so a typo or a translated
462 * value cannot land an unrecognised status in the column — the public list
463 * matches `approved` exactly, so anything else would silently hide the
464 * comment. An absent or unrecognised value falls back to `approved`, matching
465 * the column default: a CSV produced before this column existed still
466 * imports, and imported history is not dumped into a review queue.
467 *
468 * A deliberately exported `rejected`/`pending` round-trips intact, which is
469 * the point — without it, re-importing an export would republish every
470 * comment a moderator had hidden.
471 *
472 * @param mixed $value Raw CSV value.
473 * @return string One of Donations::get_valid_comment_statuses().
474 * @since 1.6.0
475 */
476 private function to_comment_status( $value ) {
477 $status = strtolower( trim( Helper::get_string_value( $value ) ) );
478
479 return in_array( $status, Donations::get_valid_comment_statuses(), true ) ? $status : 'approved';
480 }
481
482 /**
483 * Return $value, or the fallback when $value is empty.
484 *
485 * @param string $value Value.
486 * @param string $default_value Fallback.
487 * @return string
488 * @since 1.3.0
489 */
490 private function fallback( $value, $default_value ) {
491 return '' !== (string) $value ? (string) $value : $default_value;
492 }
493
494 /**
495 * Append an error message to the phase log (capped at 50 entries).
496 *
497 * @param ImportResult $result Phase results (by reference).
498 * @param string $message Message.
499 * @return void
500 * @since 1.3.0
501 */
502 private function log_error( &$result, $message ) {
503 if ( count( $result['error_log'] ) < 50 ) {
504 $result['error_log'][] = $message;
505 }
506 }
507 }
508