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

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

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