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 / givewp / donation-mapper.php

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

395 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Donation mapper for the GiveWP migration tool.
4 *
5 * Imports GiveWP donations (give_payment posts) into the
6 * suredonation_donations table. Skips rows already imported (matched by
7 * import_source_id + import_source pair), resolves the donor via Donor_Mapper, the campaign
8 * via campaign_map populated by Campaign_Mapper, and translates gateway
9 * slug + status via Status_Map.
10 *
11 * Email suppression is engaged for the duration of the batch via
12 * Email_Suppressor so receipts/admin notifications are not blasted out
13 * to donors when their historical records are inserted.
14 *
15 * @package SureDonation
16 */
17
18 namespace SureDonation\Inc\Import\Givewp;
19
20 use SureDonation\Inc\Database\Tables\Donations;
21 use SureDonation\Inc\Database\Tables\Donors;
22 use SureDonation\Inc\Traits\Get_Instance;
23
24 // Exit if accessed directly.
25 defined( 'ABSPATH' ) || exit;
26
27 /**
28 * Donation_Mapper class.
29 *
30 * @since 1.0.0
31 */
32 class Donation_Mapper {
33 use Get_Instance;
34
35 /**
36 * GiveWP payment meta keys that we map to dedicated columns.
37 * Everything else gets preserved into donation_data.givewp.meta as
38 * a raw key/value blob for later inspection.
39 */
40 const STANDARD_META_KEYS = [
41 '_give_payment_total',
42 '_give_payment_form_id',
43 '_give_payment_form_title',
44 '_give_payment_donor_id',
45 '_give_payment_donor_email',
46 '_give_payment_user_id',
47 '_give_payment_currency',
48 '_give_payment_gateway',
49 '_give_payment_mode',
50 '_give_payment_transaction_id',
51 '_give_donor_billing_first_name',
52 '_give_donor_billing_last_name',
53 '_give_donor_billing_address1',
54 '_give_donor_billing_address2',
55 '_give_donor_billing_city',
56 '_give_donor_billing_state',
57 '_give_donor_billing_zip',
58 '_give_donor_billing_country',
59 '_give_payment_customer_id',
60 // GiveWP's DonationMetaKeys::ANONYMOUS — maps to our is_anonymous column.
61 '_give_anonymous_donation',
62 ];
63
64 /**
65 * Process a batch of GiveWP payments.
66 *
67 * @param array $progress Session progress (passed by reference).
68 * @param int $offset Current offset within this phase.
69 * @return int Number of source rows processed in this batch.
70 * @since 1.0.0
71 */
72 public function process_batch( &$progress, $offset ) {
73 $source = Source::get_instance();
74 $form_ids = isset( $progress['options']['campaign_ids'] ) && is_array( $progress['options']['campaign_ids'] )
75 ? $progress['options']['campaign_ids']
76 : [];
77 $payments = $source->get_payments_batch( (int) $offset, Importer::BATCH_SIZE, $form_ids );
78
79 if ( empty( $payments ) ) {
80 return 0;
81 }
82
83 $suppressor = Email_Suppressor::get_instance();
84 $suppressor->activate();
85
86 try {
87 foreach ( $payments as $payment ) {
88 $payment_id_for_log = isset( $payment->ID ) ? (int) $payment->ID : 0;
89 try {
90 $this->process_one( $payment, $progress );
91 } catch ( \Throwable $t ) {
92 // One bad record can't kill the batch — log and move
93 // on so the rest of the migration runs to completion.
94 $this->log_error(
95 $progress,
96 $payment_id_for_log,
97 sprintf(
98 /* translators: %s: exception message */
99 __( 'Unhandled exception while processing donation: %s', 'suredonation' ),
100 $t->getMessage()
101 )
102 );
103 }
104 }
105 } finally {
106 $suppressor->deactivate();
107 }
108
109 return count( $payments );
110 }
111
112 /**
113 * Map a single GiveWP payment into a SureDonation donations row.
114 *
115 * @param object $payment GiveWP wp_posts row.
116 * @param array $progress Session progress (passed by reference).
117 * @return void
118 * @since 1.0.0
119 */
120 private function process_one( $payment, &$progress ) {
121 $give_payment_id = isset( $payment->ID ) ? (int) $payment->ID : 0;
122 if ( $give_payment_id <= 0 ) {
123 ++$progress['results']['donations']['errors'];
124 return;
125 }
126
127 // Duplicate detection via the import_source_id + import_source pair.
128 if ( $this->already_imported( $give_payment_id ) ) {
129 ++$progress['results']['donations']['skipped'];
130 return;
131 }
132
133 $source = Source::get_instance();
134 $meta = $source->get_payment_meta( $give_payment_id );
135 $email = isset( $meta['_give_payment_donor_email'] ) ? sanitize_email( $meta['_give_payment_donor_email'] ) : '';
136 $amount = $source->extract_donation_amount( $meta );
137
138 if ( '' === $email ) {
139 $this->log_error(
140 $progress,
141 $give_payment_id,
142 sprintf(
143 /* translators: %s: raw value from GiveWP payment meta */
144 __( 'Missing donor email (got "%s").', 'suredonation' ),
145 isset( $meta['_give_payment_donor_email'] ) ? (string) $meta['_give_payment_donor_email'] : ''
146 )
147 );
148 return;
149 }
150
151 if ( $amount <= 0 ) {
152 $this->log_error(
153 $progress,
154 $give_payment_id,
155 sprintf(
156 /* translators: %s: raw value from GiveWP payment meta */
157 __( 'Donation amount is zero or missing (raw _give_payment_total "%s").', 'suredonation' ),
158 isset( $meta['_give_payment_total'] ) ? (string) $meta['_give_payment_total'] : ''
159 )
160 );
161 return;
162 }
163
164 // Gateway/status translation.
165 $give_gateway = isset( $meta['_give_payment_gateway'] ) ? (string) $meta['_give_payment_gateway'] : '';
166 $gateway = Status_Map::map_gateway( $give_gateway );
167 $payment_status = Status_Map::map_donation_status( isset( $payment->post_status ) ? (string) $payment->post_status : '' );
168
169 // Track the per-gateway breakdown for results.
170 $progress['results']['donations']['gateway_breakdown'][ $gateway ] = isset( $progress['results']['donations']['gateway_breakdown'][ $gateway ] )
171 ? (int) $progress['results']['donations']['gateway_breakdown'][ $gateway ] + 1
172 : 1;
173
174 $donor_id = Donor_Mapper::get_instance()->get_or_create_for_payment( $meta, $progress );
175 if ( $donor_id <= 0 ) {
176 $this->log_error( $progress, $give_payment_id, __( 'Failed to resolve donor.', 'suredonation' ) );
177 return;
178 }
179
180 $give_form_id = isset( $meta['_give_payment_form_id'] ) ? absint( $meta['_give_payment_form_id'] ) : 0;
181 $campaign_id = 0;
182 if ( $give_form_id > 0 && isset( $progress['campaign_map'][ $give_form_id ] ) ) {
183 $campaign_id = (int) $progress['campaign_map'][ $give_form_id ];
184 }
185
186 $currency = isset( $meta['_give_payment_currency'] ) ? sanitize_text_field( $meta['_give_payment_currency'] ) : 'USD';
187 $payment_mode = isset( $meta['_give_payment_mode'] ) ? sanitize_text_field( $meta['_give_payment_mode'] ) : 'live';
188 $transaction_id = isset( $meta['_give_payment_transaction_id'] ) ? sanitize_text_field( $meta['_give_payment_transaction_id'] ) : '';
189 $customer_id = isset( $meta['_give_payment_customer_id'] ) ? sanitize_text_field( $meta['_give_payment_customer_id'] ) : '';
190
191 $first_name = isset( $meta['_give_donor_billing_first_name'] ) ? sanitize_text_field( $meta['_give_donor_billing_first_name'] ) : '';
192 $last_name = isset( $meta['_give_donor_billing_last_name'] ) ? sanitize_text_field( $meta['_give_donor_billing_last_name'] ) : '';
193 $donor_name = trim( $first_name . ' ' . $last_name );
194
195 // GiveWP marks anonymous donations with '1'. Same display-only semantics
196 // as ours: the real donor name is imported either way, and only public
197 // donor lists mask it — so a migrated wall keeps hiding the same donors.
198 $is_anonymous = isset( $meta['_give_anonymous_donation'] ) && '1' === (string) $meta['_give_anonymous_donation'];
199
200 $donation_data = [
201 'givewp' => [
202 'source_id' => $give_payment_id,
203 'import_id' => isset( $progress['import_id'] ) ? (string) $progress['import_id'] : '',
204 'form_id' => $give_form_id,
205 'form_title' => isset( $meta['_give_payment_form_title'] ) ? sanitize_text_field( $meta['_give_payment_form_title'] ) : '',
206 'gateway_raw' => $give_gateway,
207 'gateway_live' => Status_Map::is_gateway_live( $gateway ),
208 'meta' => $this->extract_extra_meta( $meta ),
209 ],
210 ];
211
212 $data = [
213 'campaign_id' => $campaign_id,
214 'donor_id' => $donor_id,
215 'form_id' => 0,
216 'amount' => (string) $amount,
217 'currency' => '' !== $currency ? $currency : 'USD',
218 'transaction_id' => $transaction_id,
219 'customer_id' => $customer_id,
220 'gateway' => $gateway,
221 'payment_status' => $payment_status,
222 'payment_mode' => 'test' === $payment_mode ? 'test' : 'live',
223 'donor_name' => $donor_name,
224 'donor_email' => $email,
225 'is_anonymous' => $is_anonymous ? 1 : 0,
226 'donation_type' => 'one-time',
227 'donation_data' => $donation_data,
228 'created_at' => isset( $payment->post_date_gmt ) ? (string) $payment->post_date_gmt : current_time( 'mysql', true ),
229 'import_source_id' => $give_payment_id,
230 'import_source' => 'givewp',
231 ];
232
233 $donation_id = Donations::add( $data );
234 if ( ! $donation_id ) {
235 global $wpdb;
236 $db_error = $wpdb->last_error ? (string) $wpdb->last_error : __( 'unknown DB error', 'suredonation' );
237 $this->log_error(
238 $progress,
239 $give_payment_id,
240 sprintf(
241 /* translators: 1: gateway slug, 2: amount, 3: DB error message */
242 __( 'Failed to insert donation row (gateway=%1$s, amount=%2$s): %3$s', 'suredonation' ),
243 $gateway,
244 (string) $amount,
245 $db_error
246 )
247 );
248 return;
249 }
250
251 // Update donor aggregates (count, total, largest, first/last date)
252 // using the actual payment date — Donors::record_donation() uses
253 // current_time() for last_donation_date, which would falsely stamp
254 // every imported donor with the import timestamp.
255 $this->update_donor_aggregates( $donor_id, $amount, (string) $data['created_at'], $payment_status );
256
257 ++$progress['results']['donations']['imported'];
258 }
259
260 /**
261 * Update an imported donor's aggregate columns after a donation insert.
262 *
263 * Mirrors Donors::record_donation() but takes the actual donation date
264 * rather than stamping current_time(), so donors imported with
265 * historical payments get the correct first/last contribution
266 * timestamps. Numeric aggregates (donation_count, total_donated,
267 * largest_donation) only accumulate for revenue-bearing statuses to
268 * match how SureDonation reports them in the dashboard.
269 *
270 * @param int $donor_id SureDonation donor row ID.
271 * @param float $amount Donation amount.
272 * @param string $donation_date ISO/MySQL datetime of the donation (GMT).
273 * @param string $payment_status SureDonation payment status enum value.
274 * @return void
275 * @since 1.0.0
276 */
277 private function update_donor_aggregates( $donor_id, $amount, $donation_date, $payment_status ) {
278 $donor = Donors::get( (int) $donor_id );
279 if ( ! is_array( $donor ) ) {
280 return;
281 }
282
283 $updates = [];
284
285 // first/last date track ALL imported payments regardless of
286 // status — cancelled or failed payments are still real
287 // historical engagement worth surfacing in the donor profile.
288 $current_first = ! empty( $donor['first_donation_date'] ) ? (string) $donor['first_donation_date'] : '';
289 $current_last = ! empty( $donor['last_donation_date'] ) ? (string) $donor['last_donation_date'] : '';
290
291 if ( '' !== $donation_date ) {
292 if ( '' === $current_first || strtotime( $donation_date ) < strtotime( $current_first ) ) {
293 $updates['first_donation_date'] = $donation_date;
294 }
295 if ( '' === $current_last || strtotime( $donation_date ) > strtotime( $current_last ) ) {
296 $updates['last_donation_date'] = $donation_date;
297 }
298 }
299
300 // Revenue-bearing counters: only completed / partially_refunded
301 // contribute (consistent with Donations::get_dashboard_stats and
302 // the campaign stats query).
303 if ( in_array( $payment_status, [ 'completed', 'partially_refunded' ], true ) ) {
304 $current_total = isset( $donor['total_donated'] ) && is_numeric( $donor['total_donated'] ) ? (float) $donor['total_donated'] : 0.0;
305 $current_count = isset( $donor['donation_count'] ) && is_numeric( $donor['donation_count'] ) ? (int) $donor['donation_count'] : 0;
306 $current_largest = isset( $donor['largest_donation'] ) && is_numeric( $donor['largest_donation'] ) ? (float) $donor['largest_donation'] : 0.0;
307
308 $updates['total_donated'] = $current_total + (float) $amount;
309 $updates['donation_count'] = $current_count + 1;
310 if ( (float) $amount > $current_largest ) {
311 $updates['largest_donation'] = (float) $amount;
312 }
313 }
314
315 if ( ! empty( $updates ) ) {
316 Donors::update( (int) $donor_id, $updates );
317 }
318 }
319
320 /**
321 * Check if a GiveWP payment has already been imported in any prior session.
322 *
323 * @param int $give_payment_id GiveWP payment ID.
324 * @return bool
325 * @since 1.0.0
326 */
327 private function already_imported( $give_payment_id ) {
328 global $wpdb;
329 $table = $wpdb->prefix . 'suredonation_donations';
330
331 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Migration scope, one row lookup.
332 $existing = $wpdb->get_var(
333 $wpdb->prepare(
334 "SELECT id FROM %i WHERE import_source_id = %d AND import_source = 'givewp' LIMIT 1",
335 $table,
336 absint( $give_payment_id )
337 )
338 );
339
340 return is_numeric( $existing ) && (int) $existing > 0;
341 }
342
343 /**
344 * Extract non-standard meta keys for preservation in donation_data.givewp.meta.
345 *
346 * Any GiveWP add-on meta not represented by a dedicated SureDonation
347 * column is preserved here as a raw key/value blob so no data is lost.
348 *
349 * @param array $meta Flat assoc of GiveWP payment meta.
350 * @return array<string,string>
351 * @since 1.0.0
352 */
353 private function extract_extra_meta( $meta ) {
354 if ( ! is_array( $meta ) ) {
355 return [];
356 }
357
358 $extra = [];
359 foreach ( $meta as $key => $value ) {
360 if ( in_array( $key, self::STANDARD_META_KEYS, true ) ) {
361 continue;
362 }
363 // Skip empty and obviously-irrelevant keys.
364 if ( '' === $value || null === $value ) {
365 continue;
366 }
367 $extra[ sanitize_key( $key ) ] = is_scalar( $value ) ? (string) $value : '';
368 }
369 return $extra;
370 }
371
372 /**
373 * Append an error entry to the donations error log, capping at 50.
374 *
375 * @param array $progress Progress (passed by reference).
376 * @param int $source_id GiveWP payment ID.
377 * @param string $message Error message.
378 * @return void
379 * @since 1.0.0
380 */
381 private function log_error( &$progress, $source_id, $message ) {
382 ++$progress['results']['donations']['errors'];
383 if ( ! isset( $progress['results']['donations']['error_log'] ) || ! is_array( $progress['results']['donations']['error_log'] ) ) {
384 $progress['results']['donations']['error_log'] = [];
385 }
386 $progress['results']['donations']['error_log'][] = [
387 'source_id' => (int) $source_id,
388 'message' => (string) $message,
389 ];
390 if ( count( $progress['results']['donations']['error_log'] ) > 50 ) {
391 $progress['results']['donations']['error_log'] = array_slice( $progress['results']['donations']['error_log'], -50 );
392 }
393 }
394 }
395