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

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

396 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Maps Charitable donations onto SureDonation donation rows.
4 *
5 * The unit of import is one charitable_campaign_donations row — Charitable
6 * donations can span multiple campaigns (one row each), and per-campaign
7 * amounts exist only on those rows, so importing per-row keeps every
8 * campaign's totals exact. The shared donation post supplies the gateway,
9 * status, donor snapshot, and meta for each of its rows.
10 *
11 * @package SureDonation
12 * @since 1.5.1
13 */
14
15 namespace SureDonation\Inc\Import\Charitable;
16
17 use SureDonation\Inc\Database\Tables\Donations;
18 use SureDonation\Inc\Database\Tables\Donors;
19 use SureDonation\Inc\Traits\Get_Instance;
20
21 // Exit if accessed directly.
22 defined( 'ABSPATH' ) || exit;
23
24 /**
25 * Donation_Mapper class.
26 *
27 * @phpstan-import-type ImportProgress from Session
28 *
29 * @since 1.5.1
30 */
31 class Donation_Mapper {
32 use Get_Instance;
33 use Provenance_Dedupe;
34
35 /**
36 * Donation meta keys mapped to dedicated columns / payload slots.
37 * Everything else is preserved raw in donation_data.charitable.meta.
38 *
39 * @since 1.5.1
40 */
41 private const STANDARD_META_KEYS = [
42 'donation_gateway',
43 'donor',
44 'test_mode',
45 'donation_key',
46 'currency',
47 'contact_consent',
48 '_gateway_transaction_id',
49 '_gateway_payment_id',
50 '_gateway_transaction_url',
51 'donation_period',
52 ];
53
54 /**
55 * Process a batch of Charitable donation rows.
56 *
57 * @param ImportProgress $progress Session progress (passed by reference).
58 * @param int $offset Current offset within this phase.
59 * @return int Number of source rows processed in this batch.
60 * @since 1.5.1
61 */
62 public function process_batch( &$progress, $offset ) {
63 $source = Source::get_instance();
64 $campaign_ids = isset( $progress['options']['campaign_ids'] ) && is_array( $progress['options']['campaign_ids'] )
65 ? $progress['options']['campaign_ids']
66 : [];
67 $rows = $source->get_donation_rows_batch( (int) $offset, Importer::BATCH_SIZE, $campaign_ids );
68
69 if ( empty( $rows ) ) {
70 return 0;
71 }
72
73 $suppressor = Email_Suppressor::get_instance();
74 $suppressor->activate();
75
76 try {
77 foreach ( $rows as $row ) {
78 $row_id_for_log = isset( $row->campaign_donation_id ) ? (int) $row->campaign_donation_id : 0;
79 try {
80 $this->process_one( $row, $progress );
81 } catch ( \Throwable $t ) {
82 // One bad record can't kill the batch — log and move on
83 // so the rest of the migration runs to completion.
84 $this->log_error(
85 $progress,
86 $row_id_for_log,
87 sprintf(
88 /* translators: %s: exception message */
89 __( 'Unhandled exception while processing donation: %s', 'suredonation' ),
90 $t->getMessage()
91 )
92 );
93 }
94 }
95 } finally {
96 $suppressor->deactivate();
97 }
98
99 return count( $rows );
100 }
101
102 /**
103 * Map a single campaign_donations row into a SureDonation donations row.
104 *
105 * @param object $row campaign_donations ⋈ donation-post row.
106 * @param ImportProgress $progress Session progress (passed by reference).
107 * @return void
108 * @since 1.5.1
109 */
110 private function process_one( $row, &$progress ) {
111 $cd_id = isset( $row->campaign_donation_id ) ? (int) $row->campaign_donation_id : 0;
112 $donation_post_id = isset( $row->donation_id ) ? (int) $row->donation_id : 0;
113 if ( $cd_id <= 0 || $donation_post_id <= 0 ) {
114 $this->log_error( $progress, $cd_id, __( 'Malformed campaign donation row.', 'suredonation' ) );
115 return;
116 }
117
118 $source_campaign_id = isset( $row->campaign_id ) ? (int) $row->campaign_id : 0;
119
120 // Dedupe on the (donation_post_id, source_campaign_id) pair via the shared
121 // indexed provenance key (Provenance_Dedupe) — the same key, recurring
122 // guard and unknown-campaign handling the CSV path uses, so a DB import
123 // and a CSV import of the same gift skip each other in both directions.
124 // import_source_id holds a different id space per path (cd_id vs donation
125 // post id) and is kept only for provenance.
126 if ( $this->provenance_seen( $donation_post_id, $source_campaign_id ) ) {
127 ++$progress['results']['donations']['skipped'];
128 return;
129 }
130
131 $source = Source::get_instance();
132 $meta = $source->get_donation_meta( $donation_post_id );
133 $snapshot = $source->extract_donor_snapshot( $meta );
134
135 $email = sanitize_email( (string) $snapshot['email'] );
136 if ( '' === $email || ! is_email( $email ) ) {
137 $donor_row = $source->get_donor( isset( $row->donor_id ) ? (int) $row->donor_id : 0 );
138 $email = is_array( $donor_row ) && isset( $donor_row['email'] ) && is_scalar( $donor_row['email'] ) ? sanitize_email( (string) $donor_row['email'] ) : '';
139 }
140 if ( '' === $email || ! is_email( $email ) ) {
141 $this->log_error( $progress, $cd_id, __( 'Donation has no resolvable donor email — skipped.', 'suredonation' ) );
142 return;
143 }
144
145 $amount = isset( $row->amount ) ? (float) $row->amount : 0.0;
146 if ( $amount <= 0 ) {
147 $this->log_error( $progress, $cd_id, __( 'Donation has no positive amount — skipped.', 'suredonation' ) );
148 return;
149 }
150
151 $donor_id = Donor_Mapper::get_instance()->get_or_create_for_donation(
152 $snapshot,
153 isset( $row->donor_id ) ? (int) $row->donor_id : 0,
154 $meta,
155 $progress
156 );
157 if ( $donor_id <= 0 ) {
158 $this->log_error( $progress, $cd_id, __( 'Could not resolve a SureDonation donor for this donation.', 'suredonation' ) );
159 return;
160 }
161
162 $gateway_raw = isset( $meta['donation_gateway'] ) ? sanitize_text_field( $meta['donation_gateway'] ) : '';
163 $gateway = Status_Map::map_gateway( $gateway_raw );
164 $payment_status = Status_Map::map_donation_status( isset( $row->post_status ) ? (string) $row->post_status : '' );
165 $post_date_gmt = isset( $row->post_date_gmt ) && is_scalar( $row->post_date_gmt ) ? (string) $row->post_date_gmt : '';
166 $post_date = isset( $row->post_date ) && is_scalar( $row->post_date ) ? (string) $row->post_date : '';
167 $created_at = '' !== $post_date_gmt && '0000-00-00 00:00:00' !== $post_date_gmt ? $post_date_gmt : $post_date;
168
169 $campaign_id = 0;
170 if ( $source_campaign_id > 0 && isset( $progress['campaign_map'][ $source_campaign_id ] ) ) {
171 $campaign_id = (int) $progress['campaign_map'][ $source_campaign_id ];
172 }
173
174 $donor_name = trim( trim( (string) $snapshot['first_name'] ) . ' ' . trim( (string) $snapshot['last_name'] ) );
175
176 $charitable_block = array_filter(
177 [
178 'source_id' => $cd_id,
179 'donation_post_id' => $donation_post_id,
180 'source_campaign_id' => $source_campaign_id,
181 'campaign_name' => isset( $row->campaign_name ) ? sanitize_text_field( (string) $row->campaign_name ) : '',
182 'import_id' => isset( $progress['import_id'] ) ? (string) $progress['import_id'] : '',
183 'donation_key' => isset( $meta['donation_key'] ) ? sanitize_text_field( $meta['donation_key'] ) : '',
184 'gateway_raw' => $gateway_raw,
185 'gateway_live' => Status_Map::is_gateway_live( $gateway ),
186 'payment_id' => isset( $meta['_gateway_payment_id'] ) ? sanitize_text_field( $meta['_gateway_payment_id'] ) : '',
187 'transaction_url' => isset( $meta['_gateway_transaction_url'] ) ? esc_url_raw( $meta['_gateway_transaction_url'] ) : '',
188 'contact_consent' => ! empty( $meta['contact_consent'] ),
189 'donation_period' => isset( $meta['donation_period'] ) ? sanitize_text_field( $meta['donation_period'] ) : '',
190 'multi_campaign' => $this->is_multi_campaign( $donation_post_id ),
191 'meta' => $this->extract_extra_meta( $meta ),
192 ],
193 static function ( $v ) {
194 if ( is_array( $v ) ) {
195 return ! empty( $v );
196 }
197 if ( is_string( $v ) ) {
198 return '' !== $v;
199 }
200 return ! empty( $v );
201 }
202 );
203
204 // gateway_live/contact_consent/multi_campaign are meaningful when
205 // false too — restore them after the truthiness filter.
206 $charitable_block['gateway_live'] = Status_Map::is_gateway_live( $gateway );
207 $charitable_block['contact_consent'] = ! empty( $meta['contact_consent'] );
208 $charitable_block['multi_campaign'] = $this->is_multi_campaign( $donation_post_id );
209
210 $donation_id = Donations::add(
211 [
212 'campaign_id' => $campaign_id,
213 'donor_id' => $donor_id,
214 'form_id' => 0,
215 'amount' => (string) $amount,
216 'currency' => isset( $meta['currency'] ) && '' !== $meta['currency'] ? sanitize_text_field( $meta['currency'] ) : 'USD',
217 'transaction_id' => isset( $meta['_gateway_transaction_id'] ) ? sanitize_text_field( $meta['_gateway_transaction_id'] ) : '',
218 'customer_id' => '',
219 'gateway' => $gateway,
220 'payment_status' => $payment_status,
221 'payment_mode' => ! empty( $meta['test_mode'] ) ? 'test' : 'live',
222 'donor_name' => $donor_name,
223 'donor_email' => $email,
224 'donation_type' => 'one-time',
225 'donation_data' => [ 'charitable' => $charitable_block ],
226 'created_at' => $created_at,
227 'import_source_id' => $cd_id,
228 'import_source' => 'charitable',
229 'import_provenance' => $this->provenance_key( $donation_post_id, $source_campaign_id ),
230 ]
231 );
232
233 if ( ! $donation_id ) {
234 $this->log_error( $progress, $cd_id, __( 'Database insert failed for donation.', 'suredonation' ) );
235 return;
236 }
237
238 ++$progress['results']['donations']['imported'];
239
240 // Record the pair so a later row in this run (or the CSV path) skips it.
241 $this->mark_provenance_seen( $donation_post_id, $source_campaign_id );
242
243 // Per-gateway breakdown for the results panel.
244 if ( ! isset( $progress['results']['donations']['gateway_breakdown'][ $gateway ] ) ) {
245 $progress['results']['donations']['gateway_breakdown'][ $gateway ] = 0;
246 }
247 ++$progress['results']['donations']['gateway_breakdown'][ $gateway ];
248
249 $this->update_donor_aggregates( $donor_id, $amount, $created_at, $payment_status );
250 }
251
252 /**
253 * Whether the donation post spans more than one campaign.
254 *
255 * @param int $donation_post_id Charitable donation post ID.
256 * @return bool
257 * @since 1.5.1
258 */
259 private function is_multi_campaign( $donation_post_id ) {
260 static $cache = [];
261 $donation_post_id = (int) $donation_post_id;
262 if ( isset( $cache[ $donation_post_id ] ) ) {
263 return $cache[ $donation_post_id ];
264 }
265
266 global $wpdb;
267
268 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Migration scope, one-shot count per donation post (memoised).
269 $count = (int) $wpdb->get_var(
270 $wpdb->prepare(
271 'SELECT COUNT(*) FROM %i WHERE donation_id = %d',
272 $wpdb->prefix . 'charitable_campaign_donations',
273 $donation_post_id
274 )
275 );
276
277 if ( count( $cache ) > 200 ) {
278 $cache = [];
279 }
280 $cache[ $donation_post_id ] = $count > 1;
281
282 return $cache[ $donation_post_id ];
283 }
284
285 /**
286 * Update donor aggregates from an imported donation.
287 *
288 * Mirrors Donors::record_donation() but takes the actual donation date
289 * rather than stamping current_time(), so donors imported with historical
290 * payments get the correct first/last contribution timestamps. Numeric
291 * aggregates only accumulate for revenue-bearing statuses to match how
292 * SureDonation reports them in the dashboard.
293 *
294 * Public so the CSV import path (Csv_Parser) reuses the exact same
295 * aggregate semantics rather than duplicating them.
296 *
297 * @param int $donor_id SureDonation donor row ID.
298 * @param float $amount Donation amount.
299 * @param string $donation_date MySQL datetime of the donation (GMT).
300 * @param string $payment_status SureDonation payment status enum value.
301 * @return void
302 * @since 1.5.1
303 */
304 public function update_donor_aggregates( $donor_id, $amount, $donation_date, $payment_status ) {
305 $donor = Donors::get( (int) $donor_id );
306 if ( ! is_array( $donor ) ) {
307 return;
308 }
309
310 $updates = [];
311
312 // first/last date track ALL imported donations regardless of status —
313 // cancelled or failed payments are still real historical engagement.
314 $current_first = ! empty( $donor['first_donation_date'] ) && is_scalar( $donor['first_donation_date'] ) ? (string) $donor['first_donation_date'] : '';
315 $current_last = ! empty( $donor['last_donation_date'] ) && is_scalar( $donor['last_donation_date'] ) ? (string) $donor['last_donation_date'] : '';
316
317 if ( '' !== $donation_date ) {
318 if ( '' === $current_first || strtotime( $donation_date ) < strtotime( $current_first ) ) {
319 $updates['first_donation_date'] = $donation_date;
320 }
321 if ( '' === $current_last || strtotime( $donation_date ) > strtotime( $current_last ) ) {
322 $updates['last_donation_date'] = $donation_date;
323 }
324 }
325
326 // Revenue-bearing counters: only completed / partially_refunded
327 // contribute (consistent with the dashboard stats queries).
328 if ( in_array( $payment_status, [ 'completed', 'partially_refunded' ], true ) ) {
329 $current_total = isset( $donor['total_donated'] ) && is_numeric( $donor['total_donated'] ) ? (float) $donor['total_donated'] : 0.0;
330 $current_count = isset( $donor['donation_count'] ) && is_numeric( $donor['donation_count'] ) ? (int) $donor['donation_count'] : 0;
331 $current_largest = isset( $donor['largest_donation'] ) && is_numeric( $donor['largest_donation'] ) ? (float) $donor['largest_donation'] : 0.0;
332
333 $updates['total_donated'] = $current_total + (float) $amount;
334 $updates['donation_count'] = $current_count + 1;
335 if ( (float) $amount > $current_largest ) {
336 $updates['largest_donation'] = (float) $amount;
337 }
338 }
339
340 if ( ! empty( $updates ) ) {
341 Donors::update( (int) $donor_id, $updates );
342 }
343 }
344
345 /**
346 * Extract non-standard meta keys for preservation in donation_data.charitable.meta.
347 *
348 * Any Charitable add-on meta not represented by a dedicated SureDonation
349 * column is preserved as a raw key/value blob so no data is lost.
350 *
351 * @param array<string, string> $meta Flat donation meta map.
352 * @return array<string, string>
353 * @since 1.5.1
354 */
355 private function extract_extra_meta( $meta ) {
356 if ( ! is_array( $meta ) ) {
357 return [];
358 }
359
360 $extra = [];
361 foreach ( $meta as $key => $value ) {
362 if ( in_array( $key, self::STANDARD_META_KEYS, true ) ) {
363 continue;
364 }
365 if ( '' === $value || null === $value ) {
366 continue;
367 }
368 $extra[ sanitize_key( $key ) ] = is_scalar( $value ) ? (string) $value : '';
369 }
370 return $extra;
371 }
372
373 /**
374 * Append an error entry to the donations error log, capping at 50.
375 *
376 * @param ImportProgress $progress Progress (passed by reference).
377 * @param int $source_id campaign_donations row ID.
378 * @param string $message Error message.
379 * @return void
380 * @since 1.5.1
381 */
382 private function log_error( &$progress, $source_id, $message ) {
383 ++$progress['results']['donations']['errors'];
384 if ( ! isset( $progress['results']['donations']['error_log'] ) || ! is_array( $progress['results']['donations']['error_log'] ) ) {
385 $progress['results']['donations']['error_log'] = [];
386 }
387 $progress['results']['donations']['error_log'][] = [
388 'source_id' => (int) $source_id,
389 'message' => (string) $message,
390 ];
391 if ( count( $progress['results']['donations']['error_log'] ) > 50 ) {
392 $progress['results']['donations']['error_log'] = array_slice( $progress['results']['donations']['error_log'], -50 );
393 }
394 }
395 }
396