PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.1
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.1
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Import / Importer.php

Importer.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.1, at includes/Import/Importer.php

491 lines 18.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Base for bringing invoices in from another product.
4 *
5 * @package Easy_Invoice
6 * @subpackage Import
7 */
8
9 namespace EasyInvoice\Import;
10
11 use EasyInvoice\Constants\ClientFields;
12 use EasyInvoice\Constants\PostTypes;
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * Why an importer
20 * ---------------
21 * Nobody moves two hundred clients and three years of invoices by hand, so
22 * without an importer the plugin can only win people who have never invoiced
23 * before. Sliced Invoices and Sprout Invoices -- the two plugins ahead of us
24 * in the directory -- keep their data as post types on the same WordPress
25 * install, which means we can read it directly, with no export step. CSV
26 * covers everything else (FreshBooks, Wave, Zoho and spreadsheets).
27 *
28 * Every importer works the same way: `preview()` counts what it would bring
29 * in, `run()` brings it in. Records remember where they came from
30 * (`_easy_invoice_import_source` / `_easy_invoice_import_source_id`), so running
31 * an import twice adds nothing, and a client that already exists here (matched
32 * by email) is reused rather than duplicated. Imported documents keep their
33 * original numbers and dates; nothing is renumbered.
34 */
35 abstract class Importer {
36
37 const META_SOURCE = '_easy_invoice_import_source';
38 const META_SOURCE_ID = '_easy_invoice_import_source_id';
39
40 /** @var array<string,int> */
41 protected $counts = [ 'clients' => 0, 'clients_matched' => 0, 'invoices' => 0, 'quotes' => 0, 'payments' => 0, 'skipped' => 0 ];
42
43 /** @var string[] */
44 protected $notes = [];
45
46 /** @var array<string,int> Source client id => our client id, for this run. */
47 protected $client_map = [];
48
49 /**
50 * Machine name, stored on every record this importer creates.
51 *
52 * @return string
53 */
54 abstract public function source(): string;
55
56 /**
57 * Human name.
58 *
59 * @return string
60 */
61 abstract public function label(): string;
62
63 /**
64 * Can this importer find anything to import on this site?
65 *
66 * @return bool
67 */
68 abstract public function available(): bool;
69
70 /**
71 * What would be imported: counts keyed like $counts, plus 'already' for
72 * records imported on an earlier run.
73 *
74 * @return array<string,int>
75 */
76 abstract public function preview(): array;
77
78 /**
79 * Do the import.
80 *
81 * @return array{counts:array<string,int>,notes:string[]}
82 */
83 abstract public function run(): array;
84
85 /**
86 * Report shape shared by every importer.
87 *
88 * @return array{counts:array<string,int>,notes:string[]}
89 */
90 protected function report(): array {
91 return [ 'counts' => $this->counts, 'notes' => $this->notes ];
92 }
93
94 /* ------------------------------------------------------------------ */
95 /* Idempotency */
96 /* ------------------------------------------------------------------ */
97
98 /**
99 * Our post id for a source record, when it was imported before.
100 *
101 * @param string $type Our post type.
102 * @param string $source_id Source id.
103 * @return int
104 */
105 protected function alreadyImported( string $type, string $source_id ): int {
106 $ids = get_posts( [
107 'post_type' => $type,
108 'post_status' => 'any',
109 'fields' => 'ids',
110 'posts_per_page' => 1,
111 'no_found_rows' => true,
112 'meta_query' => [
113 [ 'key' => self::META_SOURCE, 'value' => $this->source() ],
114 [ 'key' => self::META_SOURCE_ID, 'value' => $source_id ],
115 ],
116 ] );
117 return empty( $ids ) ? 0 : (int) $ids[0];
118 }
119
120 /**
121 * Mark a record with its origin.
122 *
123 * @param int $post_id Our post.
124 * @param string $source_id Source id.
125 * @return void
126 */
127 protected function stamp( int $post_id, string $source_id ): void {
128 update_post_meta( $post_id, self::META_SOURCE, $this->source() );
129 update_post_meta( $post_id, self::META_SOURCE_ID, $source_id );
130 }
131
132 /* ------------------------------------------------------------------ */
133 /* Clients */
134 /* ------------------------------------------------------------------ */
135
136 /**
137 * Find or create a client.
138 *
139 * Matching is by email: the same person invoiced from two systems should
140 * be one client here. A client without an email cannot be matched, so
141 * one is synthesised from the source id -- ugly, but it keeps the invoice
142 * attached to a person rather than to nobody.
143 *
144 * @param array $c Keys: email, first_name, last_name, business, address,
145 * phone, website, extra_info, source_id.
146 * @return int Client (user) id, 0 on failure.
147 */
148 protected function findOrCreateClient( array $c ): int {
149 $source_id = (string) ( $c['source_id'] ?? '' );
150 if ( '' !== $source_id && isset( $this->client_map[ $source_id ] ) ) {
151 return $this->client_map[ $source_id ];
152 }
153
154 $email = sanitize_email( (string) ( $c['email'] ?? '' ) );
155 if ( '' === $email ) {
156 $email = sanitize_email( 'client-' . $this->source() . '-' . ( '' !== $source_id ? $source_id : wp_generate_password( 8, false ) ) . '@import.invalid' );
157 }
158
159 $existing = get_user_by( 'email', $email );
160 if ( $existing ) {
161 $this->counts['clients_matched']++;
162 $this->fillMissingClientMeta( (int) $existing->ID, $c );
163 if ( '' !== $source_id ) {
164 $this->client_map[ $source_id ] = (int) $existing->ID;
165 }
166 return (int) $existing->ID;
167 }
168
169 $first = trim( (string) ( $c['first_name'] ?? '' ) );
170 $last = trim( (string) ( $c['last_name'] ?? '' ) );
171 if ( '' === $first && '' === $last ) {
172 $first = trim( (string) ( $c['business'] ?? '' ) ) ?: strstr( $email, '@', true );
173 }
174 $login = sanitize_user( strstr( $email, '@', true ), true ) ?: 'client';
175 $base = $login;
176 $n = 1;
177 while ( username_exists( $login ) ) {
178 $login = $base . '-' . ( ++$n );
179 }
180
181 $repo = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
182 $client = $repo->create( [
183 ClientFields::EMAIL => $email,
184 ClientFields::USERNAME => $login,
185 ClientFields::FIRST_NAME => $first,
186 ClientFields::LAST_NAME => $last,
187 ClientFields::BUSINESS_CLIENT_NAME => (string) ( $c['business'] ?? '' ),
188 ClientFields::ADDRESS => (string) ( $c['address'] ?? '' ),
189 ClientFields::PHONE => (string) ( $c['phone'] ?? '' ),
190 ClientFields::WEBSITE => (string) ( $c['website'] ?? '' ),
191 ClientFields::EXTRA_INFO => (string) ( $c['extra_info'] ?? '' ),
192 ] );
193
194 if ( ! $client ) {
195 $this->notes[] = sprintf( 'Could not create client %s.', $email );
196 return 0;
197 }
198
199 $id = (int) $client->getId();
200 update_user_meta( $id, self::META_SOURCE, $this->source() );
201 if ( '' !== $source_id ) {
202 update_user_meta( $id, self::META_SOURCE_ID, $source_id );
203 $this->client_map[ $source_id ] = $id;
204 }
205 $this->counts['clients']++;
206
207 return $id;
208 }
209
210 /**
211 * An existing client keeps what they have; only empty fields are filled.
212 *
213 * @param int $user_id Client.
214 * @param array $c Incoming values.
215 * @return void
216 */
217 private function fillMissingClientMeta( int $user_id, array $c ): void {
218 $map = [
219 'business' => ClientFields::BUSINESS_CLIENT_NAME,
220 'address' => ClientFields::ADDRESS,
221 'phone' => ClientFields::PHONE,
222 'website' => ClientFields::WEBSITE,
223 'extra_info' => ClientFields::EXTRA_INFO,
224 ];
225 foreach ( $map as $key => $meta ) {
226 $value = trim( (string) ( $c[ $key ] ?? '' ) );
227 if ( '' !== $value && '' === (string) get_user_meta( $user_id, $meta, true ) ) {
228 update_user_meta( $user_id, $meta, $value );
229 }
230 }
231 }
232
233 /* ------------------------------------------------------------------ */
234 /* Documents */
235 /* ------------------------------------------------------------------ */
236
237 /**
238 * Create an invoice from normalised data.
239 *
240 * @param array $d Keys: source_id, title, number, status
241 * (draft|available|paid|overdue|cancelled), issue_date,
242 * due_date (Y-m-d), client_id (ours), customer_name,
243 * customer_email, customer_address, items[], tax_rate,
244 * discount_type (percentage|fixed), discount_value,
245 * currency_code, notes, terms, created (mysql).
246 * @return int Invoice id, 0 on failure.
247 */
248 protected function createInvoice( array $d ): int {
249 $existing = $this->alreadyImported( PostTypes::EASY_INVOICE_POST_TYPE, (string) $d['source_id'] );
250 if ( $existing > 0 ) {
251 $this->counts['skipped']++;
252 return $existing;
253 }
254
255 $data = $this->documentData( $d );
256 $data['status'] = $d['status'] ?? 'available';
257 if ( ! empty( $d['due_date'] ) ) {
258 $data['due_date'] = $d['due_date'];
259 }
260
261 $repo = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
262 $invoice = $repo->create( $data );
263 if ( ! $invoice ) {
264 $this->notes[] = sprintf( 'Invoice %s could not be created.', $d['number'] ?? $d['source_id'] );
265 return 0;
266 }
267 $id = (int) $invoice->getId();
268 $this->afterDocument( $id, $d );
269 $this->counts['invoices']++;
270
271 return $id;
272 }
273
274 /**
275 * Create a quote from normalised data (same keys as createInvoice, plus
276 * expiry_date; status draft|available|sent|accepted|declined|expired).
277 *
278 * @param array $d Data.
279 * @return int Quote id, 0 on failure.
280 */
281 protected function createQuote( array $d ): int {
282 $existing = $this->alreadyImported( PostTypes::EASY_INVOICE_QUOTE_POST_TYPE, (string) $d['source_id'] );
283 if ( $existing > 0 ) {
284 $this->counts['skipped']++;
285 return $existing;
286 }
287
288 $data = $this->documentData( $d );
289 $data['status'] = $d['status'] ?? 'available';
290 if ( ! empty( $d['expiry_date'] ) ) {
291 $data['expiry_date'] = $d['expiry_date'];
292 }
293
294 $repo = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
295 $quote = $repo->create( $data );
296 if ( ! $quote ) {
297 $this->notes[] = sprintf( 'Quote %s could not be created.', $d['number'] ?? $d['source_id'] );
298 return 0;
299 }
300 $id = (int) $quote->getId();
301 $this->afterDocument( $id, $d );
302 $this->counts['quotes']++;
303
304 return $id;
305 }
306
307 /**
308 * The part of the data both document types share.
309 *
310 * @param array $d Normalised data.
311 * @return array
312 */
313 private function documentData( array $d ): array {
314 $items = [];
315 foreach ( (array) ( $d['items'] ?? [] ) as $it ) {
316 $qty = (float) ( $it['quantity'] ?? 1 );
317 $price = (float) ( $it['price'] ?? 0 );
318 $items[] = [
319 'name' => (string) ( $it['name'] ?? '' ),
320 'description' => (string) ( $it['description'] ?? '' ),
321 'quantity' => $qty,
322 'price' => $price,
323 'amount' => round( $qty * $price, 2 ),
324 'adjust_percentage' => (float) ( $it['adjust_percentage'] ?? 0 ),
325 'taxable' => ! isset( $it['taxable'] ) || (bool) $it['taxable'],
326 ];
327 }
328
329 $data = [
330 'title' => (string) ( $d['title'] ?? '' ),
331 'number' => (string) ( $d['number'] ?? '' ),
332 'issue_date' => (string) ( $d['issue_date'] ?? current_time('Y-m-d') ),
333 'customer_name' => (string) ( $d['customer_name'] ?? '' ),
334 'customer_email' => (string) ( $d['customer_email'] ?? '' ),
335 'customer_address' => (string) ( $d['customer_address'] ?? '' ),
336 'items' => $items,
337 'notes' => (string) ( $d['notes'] ?? '' ),
338 'terms_and_conditions' => (string) ( $d['terms'] ?? '' ),
339 'currency_code' => (string) ( $d['currency_code'] ?? '' ) ?: 'global',
340 ];
341 if ( ! empty( $d['client_id'] ) ) {
342 $data['client_id'] = (int) $d['client_id'];
343 }
344 $tax = (float) ( $d['tax_rate'] ?? 0 );
345 $data['tax_enabled'] = $tax > 0 ? 'yes' : 'no';
346 $data['tax_rate'] = $tax;
347 if ( ! empty( $d['discount_value'] ) ) {
348 $data['discount_type'] = ( 'percentage' === ( $d['discount_type'] ?? '' ) ) ? 'percentage' : 'fixed';
349 $data['discount_value'] = (float) $d['discount_value'];
350 }
351
352 return $data;
353 }
354
355 /**
356 * Stamp the origin and restore the original creation date.
357 *
358 * @param int $id Our post.
359 * @param array $d Normalised data.
360 * @return void
361 */
362 private function afterDocument( int $id, array $d ): void {
363 $this->stamp( $id, (string) $d['source_id'] );
364 if ( ! empty( $d['created'] ) ) {
365 wp_update_post( [ 'ID' => $id, 'post_date' => $d['created'], 'post_date_gmt' => get_gmt_from_date( $d['created'] ) ] );
366 }
367 }
368
369 /**
370 * Record a payment against an imported invoice.
371 *
372 * @param int $invoice_id Our invoice.
373 * @param array $p Keys: source_id, amount, date (mysql), method,
374 * transaction_id, status (completed|pending|refunded|failed), notes.
375 * @return int Payment id, 0 on failure.
376 */
377 protected function createPayment( int $invoice_id, array $p ): int {
378 $existing = $this->alreadyImported( PostTypes::EASY_INVOICE_PAYMENT_POST_TYPE, (string) $p['source_id'] );
379 if ( $existing > 0 ) {
380 $this->counts['skipped']++;
381 return $existing;
382 }
383
384 $invoice = new \EasyInvoice\Models\Invoice( get_post( $invoice_id ) );
385 $currency = (string) $invoice->getCurrencyCode();
386 if ( '' === $currency || 'global' === $currency ) {
387 $currency = (string) get_option( 'easy_invoice_currency_code', 'USD' );
388 }
389 $date = (string) ( $p['date'] ?? '' ) ?: current_time( 'mysql' );
390
391 $id = wp_insert_post( [
392 'post_title' => sprintf( 'Payment for Invoice #%s', $invoice->getNumber() ),
393 'post_type' => PostTypes::EASY_INVOICE_PAYMENT_POST_TYPE,
394 'post_status' => 'publish',
395 'post_date' => $date,
396 'post_author' => get_current_user_id(),
397 'meta_input' => [
398 '_invoice_id' => $invoice_id,
399 '_amount' => round( (float) ( $p['amount'] ?? 0 ), 2 ),
400 '_payment_method' => (string) ( $p['method'] ?? 'imported' ),
401 '_status' => (string) ( $p['status'] ?? 'completed' ),
402 '_transaction_id' => (string) ( $p['transaction_id'] ?? '' ),
403 '_payment_date' => $date,
404 '_notes' => (string) ( $p['notes'] ?? '' ),
405 '_payment_type' => 'imported',
406 '_currency' => $currency,
407 '_currency_symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol( $currency ),
408 self::META_SOURCE => $this->source(),
409 self::META_SOURCE_ID => (string) $p['source_id'],
410 ],
411 ] );
412
413 if ( is_wp_error( $id ) || ! $id ) {
414 return 0;
415 }
416 $this->counts['payments']++;
417
418 return (int) $id;
419 }
420
421 /* ------------------------------------------------------------------ */
422 /* Small helpers */
423 /* ------------------------------------------------------------------ */
424
425 /**
426 * A Unix timestamp or date string as Y-m-d, '' when unusable.
427 *
428 * @param mixed $value Timestamp or string.
429 * @return string
430 */
431 protected function toDate( $value ): string {
432 if ( is_numeric( $value ) && (int) $value > 0 ) {
433 return gmdate( 'Y-m-d', (int) $value );
434 }
435 $ts = is_string( $value ) && '' !== $value ? strtotime( $value ) : false;
436 return $ts ? gmdate( 'Y-m-d', $ts ) : '';
437 }
438
439 /**
440 * Same, as a MySQL datetime.
441 *
442 * @param mixed $value Timestamp or string.
443 * @return string
444 */
445 protected function toDateTime( $value ): string {
446 if ( is_numeric( $value ) && (int) $value > 0 ) {
447 return gmdate( 'Y-m-d H:i:s', (int) $value );
448 }
449 $ts = is_string( $value ) && '' !== $value ? strtotime( $value ) : false;
450 return $ts ? gmdate( 'Y-m-d H:i:s', $ts ) : '';
451 }
452
453 /**
454 * A number that may carry a currency symbol or thousands separator.
455 *
456 * @param mixed $value Raw.
457 * @return float
458 */
459 protected function toNumber( $value ): float {
460 if ( is_numeric( $value ) ) {
461 return (float) $value;
462 }
463 $clean = preg_replace( '/[^0-9.,\-]/', '', (string) $value );
464 // "1.234,56" vs "1,234.56": the last separator is the decimal point.
465 $last_comma = strrpos( $clean, ',' );
466 $last_dot = strrpos( $clean, '.' );
467 if ( false !== $last_comma && ( false === $last_dot || $last_comma > $last_dot ) ) {
468 $clean = str_replace( '.', '', $clean );
469 $clean = str_replace( ',', '.', $clean );
470 } else {
471 $clean = str_replace( ',', '', $clean );
472 }
473 return (float) $clean;
474 }
475
476 /**
477 * Client name split into first / last.
478 *
479 * @param string $name Full name.
480 * @return array{0:string,1:string}
481 */
482 protected function splitName( string $name ): array {
483 $name = trim( $name );
484 if ( '' === $name ) {
485 return [ '', '' ];
486 }
487 $parts = preg_split( '/\s+/', $name, 2 );
488 return [ $parts[0], $parts[1] ?? '' ];
489 }
490 }
491