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 / givewp / csv-parser.php

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

306 lines 9.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * GiveWP CSV donation export parser.
4 *
5 * Parses the CSV file produced by GiveWP » Donations » Tools » Exports
6 * and inserts each row as a SureDonation donation, reusing
7 * Donor_Mapper + Status_Map so the logic stays consistent with the
8 * direct DB path.
9 *
10 * Synchronous single-request flow — for very large files this could be
11 * lifted into a batched/streamed mode later; current implementation
12 * processes the whole file in one request and returns aggregated
13 * results.
14 *
15 * @package SureDonation
16 */
17
18 namespace SureDonation\Inc\Import\Givewp;
19
20 use SureDonation\Inc\Database\Tables\Donations;
21 use SureDonation\Inc\Traits\Get_Instance;
22
23 // Exit if accessed directly.
24 defined( 'ABSPATH' ) || exit;
25
26 /**
27 * Csv_Parser class.
28 *
29 * @since 1.0.0
30 */
31 class Csv_Parser {
32 use Get_Instance;
33
34 /**
35 * Hard cap on rows processed per upload. Guards against accidental
36 * (or malicious) huge files that would OOM or time out the request.
37 * GiveWP donation exports rarely exceed 100k rows; sites that need
38 * more should chunk their exports.
39 *
40 * @since 1.0.0
41 */
42 const MAX_ROWS = 100000;
43
44 /**
45 * GiveWP donation export column headers we know how to map.
46 * Header names match GiveWP's default CSV export field labels.
47 */
48 const COLUMN_MAP = [
49 'donation id' => 'givewp_donation_id',
50 'donation total' => 'amount',
51 'donation status' => 'status',
52 'payment gateway' => 'gateway',
53 'currency code' => 'currency',
54 'donor first name' => 'first_name',
55 'donor last name' => 'last_name',
56 'donor email' => 'email',
57 'form title' => 'form_title',
58 'donation date' => 'date',
59 'donation id (legacy)' => 'givewp_donation_id',
60 ];
61
62 /**
63 * Parse a GiveWP CSV file and import its rows.
64 *
65 * @param string $file_path Absolute path to a readable CSV file.
66 * @return array{imported:int,skipped:int,errors:int,error_log:array<int,array{row:int,message:string}>,gateway_breakdown:array<string,int>}
67 * @since 1.0.0
68 */
69 public function parse_donations( $file_path ) {
70 $results = [
71 'imported' => 0,
72 'skipped' => 0,
73 'errors' => 0,
74 'error_log' => [],
75 'gateway_breakdown' => [],
76 ];
77
78 if ( ! is_string( $file_path ) || ! is_readable( $file_path ) ) {
79 ++$results['errors'];
80 $results['error_log'][] = [
81 'row' => 0,
82 'message' => __( 'CSV file is not readable.', 'suredonation' ),
83 ];
84 return $results;
85 }
86
87 $handle = fopen( $file_path, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming a user-uploaded CSV.
88 if ( false === $handle ) {
89 ++$results['errors'];
90 $results['error_log'][] = [
91 'row' => 0,
92 'message' => __( 'Could not open CSV file.', 'suredonation' ),
93 ];
94 return $results;
95 }
96
97 $header_row = fgetcsv( $handle );
98 if ( false === $header_row || empty( $header_row ) ) {
99 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
100 ++$results['errors'];
101 $results['error_log'][] = [
102 'row' => 0,
103 'message' => __( 'CSV is empty or missing header row.', 'suredonation' ),
104 ];
105 return $results;
106 }
107
108 $column_index = $this->build_column_index( $header_row );
109
110 // Engage email suppression for the duration of the import.
111 $suppressor = Email_Suppressor::get_instance();
112 $suppressor->activate();
113
114 $row_number = 1; // header was row 1.
115 $progress = [ 'donor_map' => [] ];
116
117 try {
118 while ( false !== ( $row = fgetcsv( $handle ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition -- Idiomatic CSV stream.
119 ++$row_number;
120 if ( $row_number - 1 > self::MAX_ROWS ) {
121 ++$results['errors'];
122 $results['error_log'][] = [
123 'row' => (int) $row_number,
124 'message' => sprintf(
125 /* translators: %d: row cap for a single CSV upload. */
126 __(
127 'CSV exceeds the per-upload row cap (%d). Split the export into smaller files.',
128 'suredonation'
129 ),
130 (int) self::MAX_ROWS
131 ),
132 ];
133 break;
134 }
135 $this->process_row( $row, $column_index, $progress, $results, $row_number );
136 }
137 } finally {
138 $suppressor->deactivate();
139 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
140 }
141
142 return $results;
143 }
144
145 /**
146 * Build a map of canonical field => column index from the header row.
147 *
148 * @param array<int,string> $header_row Raw header row.
149 * @return array<string,int>
150 * @since 1.0.0
151 */
152 private function build_column_index( $header_row ) {
153 $index = [];
154 foreach ( $header_row as $col => $heading ) {
155 $key = strtolower( trim( (string) $heading ) );
156 if ( isset( self::COLUMN_MAP[ $key ] ) ) {
157 $index[ self::COLUMN_MAP[ $key ] ] = (int) $col;
158 }
159 }
160 return $index;
161 }
162
163 /**
164 * Process a single CSV row.
165 *
166 * @param array<int,string> $row Raw row values.
167 * @param array<string,int> $column_index field => column index.
168 * @param array $progress Progress holder (donor_map cache).
169 * @param array $results Aggregate results (passed by reference).
170 * @param int $row_number 1-based row number for error reporting.
171 * @return void
172 * @since 1.0.0
173 */
174 private function process_row( $row, $column_index, &$progress, &$results, $row_number ) {
175 $value = static function ( $field ) use ( $row, $column_index ) {
176 if ( ! isset( $column_index[ $field ] ) ) {
177 return '';
178 }
179 $col = $column_index[ $field ];
180 return isset( $row[ $col ] ) ? trim( (string) $row[ $col ] ) : '';
181 };
182
183 $email = sanitize_email( $value( 'email' ) );
184 $amount = (float) $value( 'amount' );
185 $first = sanitize_text_field( $value( 'first_name' ) );
186 $last = sanitize_text_field( $value( 'last_name' ) );
187
188 if ( '' === $email || $amount <= 0 ) {
189 ++$results['errors'];
190 $results['error_log'][] = [
191 'row' => (int) $row_number,
192 'message' => __( 'Missing email or zero amount.', 'suredonation' ),
193 ];
194 return;
195 }
196
197 $give_donation_id = absint( $value( 'givewp_donation_id' ) );
198
199 // Duplicate detection — skip rows already imported (either via direct DB path or a previous CSV run).
200 if ( $give_donation_id > 0 && $this->already_imported( $give_donation_id ) ) {
201 ++$results['skipped'];
202 return;
203 }
204
205 // Resolve donor via the existing mapper (synthesise the payment_meta shape it expects).
206 $payment_meta = [
207 '_give_payment_donor_email' => $email,
208 '_give_donor_billing_first_name' => $first,
209 '_give_donor_billing_last_name' => $last,
210 ];
211 $donor_id = Donor_Mapper::get_instance()->get_or_create_for_payment( $payment_meta, $progress );
212
213 if ( $donor_id <= 0 ) {
214 ++$results['errors'];
215 $results['error_log'][] = [
216 'row' => (int) $row_number,
217 'message' => __( 'Failed to resolve donor.', 'suredonation' ),
218 ];
219 return;
220 }
221
222 $give_gateway = $value( 'gateway' );
223 $gateway = Status_Map::map_gateway( $give_gateway );
224 $status = Status_Map::map_donation_status( $value( 'status' ) );
225 $currency = strtoupper( $value( 'currency' ) );
226 $donor_name = trim( $first . ' ' . $last );
227 $date = $value( 'date' );
228
229 $results['gateway_breakdown'][ $gateway ] = isset( $results['gateway_breakdown'][ $gateway ] )
230 ? (int) $results['gateway_breakdown'][ $gateway ] + 1
231 : 1;
232
233 $donation_data = [
234 'givewp' => [
235 'source_id' => $give_donation_id,
236 'form_title' => sanitize_text_field( $value( 'form_title' ) ),
237 'gateway_raw' => $give_gateway,
238 'gateway_live' => Status_Map::is_gateway_live( $gateway ),
239 'csv_row' => (int) $row_number,
240 ],
241 ];
242
243 $donation_id = Donations::add(
244 [
245 'campaign_id' => 0,
246 'donor_id' => $donor_id,
247 'form_id' => 0,
248 'amount' => (string) $amount,
249 'currency' => '' !== $currency ? $currency : 'USD',
250 'transaction_id' => '',
251 'customer_id' => '',
252 'gateway' => $gateway,
253 'payment_status' => $status,
254 'payment_mode' => 'live',
255 'donor_name' => $donor_name,
256 'donor_email' => $email,
257 'donation_type' => 'one-time',
258 'donation_data' => $donation_data,
259 'created_at' => '' !== $date ? $date : current_time( 'mysql', true ),
260 'import_source_id' => $give_donation_id,
261 'import_source' => 'givewp',
262 ]
263 );
264
265 if ( ! $donation_id ) {
266 ++$results['errors'];
267 $results['error_log'][] = [
268 'row' => (int) $row_number,
269 'message' => __( 'Failed to insert donation row.', 'suredonation' ),
270 ];
271 return;
272 }
273
274 ++$results['imported'];
275
276 // Keep error log bounded.
277 if ( count( $results['error_log'] ) > 50 ) {
278 $results['error_log'] = array_slice( $results['error_log'], -50 );
279 }
280 }
281
282 /**
283 * Check whether a GiveWP donation source ID has already been imported.
284 *
285 * @param int $give_donation_id GiveWP donation ID.
286 * @return bool
287 * @since 1.0.0
288 */
289 private function already_imported( $give_donation_id ) {
290 global $wpdb;
291 $table = $wpdb->prefix . 'suredonation_donations';
292
293 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
294 $existing = $wpdb->get_var(
295 $wpdb->prepare(
296 'SELECT id FROM %i WHERE import_source_id = %d AND import_source = %s LIMIT 1',
297 $table,
298 absint( $give_donation_id ),
299 'givewp'
300 )
301 );
302
303 return is_numeric( $existing ) && (int) $existing > 0;
304 }
305 }
306