*/ protected $counts = [ 'clients' => 0, 'clients_matched' => 0, 'invoices' => 0, 'quotes' => 0, 'payments' => 0, 'skipped' => 0 ]; /** @var string[] */ protected $notes = []; /** @var array Source client id => our client id, for this run. */ protected $client_map = []; /** * Machine name, stored on every record this importer creates. * * @return string */ abstract public function source(): string; /** * Human name. * * @return string */ abstract public function label(): string; /** * Can this importer find anything to import on this site? * * @return bool */ abstract public function available(): bool; /** * What would be imported: counts keyed like $counts, plus 'already' for * records imported on an earlier run. * * @return array */ abstract public function preview(): array; /** * Do the import. * * @return array{counts:array,notes:string[]} */ abstract public function run(): array; /** * Report shape shared by every importer. * * @return array{counts:array,notes:string[]} */ protected function report(): array { return [ 'counts' => $this->counts, 'notes' => $this->notes ]; } /* ------------------------------------------------------------------ */ /* Idempotency */ /* ------------------------------------------------------------------ */ /** * Our post id for a source record, when it was imported before. * * @param string $type Our post type. * @param string $source_id Source id. * @return int */ protected function alreadyImported( string $type, string $source_id ): int { $ids = get_posts( [ 'post_type' => $type, 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => 1, 'no_found_rows' => true, 'meta_query' => [ [ 'key' => self::META_SOURCE, 'value' => $this->source() ], [ 'key' => self::META_SOURCE_ID, 'value' => $source_id ], ], ] ); return empty( $ids ) ? 0 : (int) $ids[0]; } /** * Mark a record with its origin. * * @param int $post_id Our post. * @param string $source_id Source id. * @return void */ protected function stamp( int $post_id, string $source_id ): void { update_post_meta( $post_id, self::META_SOURCE, $this->source() ); update_post_meta( $post_id, self::META_SOURCE_ID, $source_id ); } /* ------------------------------------------------------------------ */ /* Clients */ /* ------------------------------------------------------------------ */ /** * Find or create a client. * * Matching is by email: the same person invoiced from two systems should * be one client here. A client without an email cannot be matched, so * one is synthesised from the source id -- ugly, but it keeps the invoice * attached to a person rather than to nobody. * * @param array $c Keys: email, first_name, last_name, business, address, * phone, website, extra_info, source_id. * @return int Client (user) id, 0 on failure. */ protected function findOrCreateClient( array $c ): int { $source_id = (string) ( $c['source_id'] ?? '' ); if ( '' !== $source_id && isset( $this->client_map[ $source_id ] ) ) { return $this->client_map[ $source_id ]; } $email = sanitize_email( (string) ( $c['email'] ?? '' ) ); if ( '' === $email ) { $email = sanitize_email( 'client-' . $this->source() . '-' . ( '' !== $source_id ? $source_id : wp_generate_password( 8, false ) ) . '@import.invalid' ); } $existing = get_user_by( 'email', $email ); if ( $existing ) { $this->counts['clients_matched']++; $this->fillMissingClientMeta( (int) $existing->ID, $c ); if ( '' !== $source_id ) { $this->client_map[ $source_id ] = (int) $existing->ID; } return (int) $existing->ID; } $first = trim( (string) ( $c['first_name'] ?? '' ) ); $last = trim( (string) ( $c['last_name'] ?? '' ) ); if ( '' === $first && '' === $last ) { $first = trim( (string) ( $c['business'] ?? '' ) ) ?: strstr( $email, '@', true ); } $login = sanitize_user( strstr( $email, '@', true ), true ) ?: 'client'; $base = $login; $n = 1; while ( username_exists( $login ) ) { $login = $base . '-' . ( ++$n ); } $repo = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository(); $client = $repo->create( [ ClientFields::EMAIL => $email, ClientFields::USERNAME => $login, ClientFields::FIRST_NAME => $first, ClientFields::LAST_NAME => $last, ClientFields::BUSINESS_CLIENT_NAME => (string) ( $c['business'] ?? '' ), ClientFields::ADDRESS => (string) ( $c['address'] ?? '' ), ClientFields::PHONE => (string) ( $c['phone'] ?? '' ), ClientFields::WEBSITE => (string) ( $c['website'] ?? '' ), ClientFields::EXTRA_INFO => (string) ( $c['extra_info'] ?? '' ), ] ); if ( ! $client ) { $this->notes[] = sprintf( 'Could not create client %s.', $email ); return 0; } $id = (int) $client->getId(); update_user_meta( $id, self::META_SOURCE, $this->source() ); if ( '' !== $source_id ) { update_user_meta( $id, self::META_SOURCE_ID, $source_id ); $this->client_map[ $source_id ] = $id; } $this->counts['clients']++; return $id; } /** * An existing client keeps what they have; only empty fields are filled. * * @param int $user_id Client. * @param array $c Incoming values. * @return void */ private function fillMissingClientMeta( int $user_id, array $c ): void { $map = [ 'business' => ClientFields::BUSINESS_CLIENT_NAME, 'address' => ClientFields::ADDRESS, 'phone' => ClientFields::PHONE, 'website' => ClientFields::WEBSITE, 'extra_info' => ClientFields::EXTRA_INFO, ]; foreach ( $map as $key => $meta ) { $value = trim( (string) ( $c[ $key ] ?? '' ) ); if ( '' !== $value && '' === (string) get_user_meta( $user_id, $meta, true ) ) { update_user_meta( $user_id, $meta, $value ); } } } /* ------------------------------------------------------------------ */ /* Documents */ /* ------------------------------------------------------------------ */ /** * Create an invoice from normalised data. * * @param array $d Keys: source_id, title, number, status * (draft|available|paid|overdue|cancelled), issue_date, * due_date (Y-m-d), client_id (ours), customer_name, * customer_email, customer_address, items[], tax_rate, * discount_type (percentage|fixed), discount_value, * currency_code, notes, terms, created (mysql). * @return int Invoice id, 0 on failure. */ protected function createInvoice( array $d ): int { $existing = $this->alreadyImported( PostTypes::EASY_INVOICE_POST_TYPE, (string) $d['source_id'] ); if ( $existing > 0 ) { $this->counts['skipped']++; return $existing; } $data = $this->documentData( $d ); $data['status'] = $d['status'] ?? 'available'; if ( ! empty( $d['due_date'] ) ) { $data['due_date'] = $d['due_date']; } $repo = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository(); $invoice = $repo->create( $data ); if ( ! $invoice ) { $this->notes[] = sprintf( 'Invoice %s could not be created.', $d['number'] ?? $d['source_id'] ); return 0; } $id = (int) $invoice->getId(); $this->afterDocument( $id, $d ); $this->counts['invoices']++; return $id; } /** * Create a quote from normalised data (same keys as createInvoice, plus * expiry_date; status draft|available|sent|accepted|declined|expired). * * @param array $d Data. * @return int Quote id, 0 on failure. */ protected function createQuote( array $d ): int { $existing = $this->alreadyImported( PostTypes::EASY_INVOICE_QUOTE_POST_TYPE, (string) $d['source_id'] ); if ( $existing > 0 ) { $this->counts['skipped']++; return $existing; } $data = $this->documentData( $d ); $data['status'] = $d['status'] ?? 'available'; if ( ! empty( $d['expiry_date'] ) ) { $data['expiry_date'] = $d['expiry_date']; } $repo = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository(); $quote = $repo->create( $data ); if ( ! $quote ) { $this->notes[] = sprintf( 'Quote %s could not be created.', $d['number'] ?? $d['source_id'] ); return 0; } $id = (int) $quote->getId(); $this->afterDocument( $id, $d ); $this->counts['quotes']++; return $id; } /** * The part of the data both document types share. * * @param array $d Normalised data. * @return array */ private function documentData( array $d ): array { $items = []; foreach ( (array) ( $d['items'] ?? [] ) as $it ) { $qty = (float) ( $it['quantity'] ?? 1 ); $price = (float) ( $it['price'] ?? 0 ); $items[] = [ 'name' => (string) ( $it['name'] ?? '' ), 'description' => (string) ( $it['description'] ?? '' ), 'quantity' => $qty, 'price' => $price, 'amount' => round( $qty * $price, 2 ), 'adjust_percentage' => (float) ( $it['adjust_percentage'] ?? 0 ), 'taxable' => ! isset( $it['taxable'] ) || (bool) $it['taxable'], ]; } $data = [ 'title' => (string) ( $d['title'] ?? '' ), 'number' => (string) ( $d['number'] ?? '' ), 'issue_date' => (string) ( $d['issue_date'] ?? current_time('Y-m-d') ), 'customer_name' => (string) ( $d['customer_name'] ?? '' ), 'customer_email' => (string) ( $d['customer_email'] ?? '' ), 'customer_address' => (string) ( $d['customer_address'] ?? '' ), 'items' => $items, 'notes' => (string) ( $d['notes'] ?? '' ), 'terms_and_conditions' => (string) ( $d['terms'] ?? '' ), 'currency_code' => (string) ( $d['currency_code'] ?? '' ) ?: 'global', ]; if ( ! empty( $d['client_id'] ) ) { $data['client_id'] = (int) $d['client_id']; } $tax = (float) ( $d['tax_rate'] ?? 0 ); $data['tax_enabled'] = $tax > 0 ? 'yes' : 'no'; $data['tax_rate'] = $tax; if ( ! empty( $d['discount_value'] ) ) { $data['discount_type'] = ( 'percentage' === ( $d['discount_type'] ?? '' ) ) ? 'percentage' : 'fixed'; $data['discount_value'] = (float) $d['discount_value']; } return $data; } /** * Stamp the origin and restore the original creation date. * * @param int $id Our post. * @param array $d Normalised data. * @return void */ private function afterDocument( int $id, array $d ): void { $this->stamp( $id, (string) $d['source_id'] ); if ( ! empty( $d['created'] ) ) { wp_update_post( [ 'ID' => $id, 'post_date' => $d['created'], 'post_date_gmt' => get_gmt_from_date( $d['created'] ) ] ); } } /** * Record a payment against an imported invoice. * * @param int $invoice_id Our invoice. * @param array $p Keys: source_id, amount, date (mysql), method, * transaction_id, status (completed|pending|refunded|failed), notes. * @return int Payment id, 0 on failure. */ protected function createPayment( int $invoice_id, array $p ): int { $existing = $this->alreadyImported( PostTypes::EASY_INVOICE_PAYMENT_POST_TYPE, (string) $p['source_id'] ); if ( $existing > 0 ) { $this->counts['skipped']++; return $existing; } $invoice = new \EasyInvoice\Models\Invoice( get_post( $invoice_id ) ); $currency = (string) $invoice->getCurrencyCode(); if ( '' === $currency || 'global' === $currency ) { $currency = (string) get_option( 'easy_invoice_currency_code', 'USD' ); } $date = (string) ( $p['date'] ?? '' ) ?: current_time( 'mysql' ); $id = wp_insert_post( [ 'post_title' => sprintf( 'Payment for Invoice #%s', $invoice->getNumber() ), 'post_type' => PostTypes::EASY_INVOICE_PAYMENT_POST_TYPE, 'post_status' => 'publish', 'post_date' => $date, 'post_author' => get_current_user_id(), 'meta_input' => [ '_invoice_id' => $invoice_id, '_amount' => round( (float) ( $p['amount'] ?? 0 ), 2 ), '_payment_method' => (string) ( $p['method'] ?? 'imported' ), '_status' => (string) ( $p['status'] ?? 'completed' ), '_transaction_id' => (string) ( $p['transaction_id'] ?? '' ), '_payment_date' => $date, '_notes' => (string) ( $p['notes'] ?? '' ), '_payment_type' => 'imported', '_currency' => $currency, '_currency_symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol( $currency ), self::META_SOURCE => $this->source(), self::META_SOURCE_ID => (string) $p['source_id'], ], ] ); if ( is_wp_error( $id ) || ! $id ) { return 0; } $this->counts['payments']++; return (int) $id; } /* ------------------------------------------------------------------ */ /* Small helpers */ /* ------------------------------------------------------------------ */ /** * A Unix timestamp or date string as Y-m-d, '' when unusable. * * @param mixed $value Timestamp or string. * @return string */ protected function toDate( $value ): string { if ( is_numeric( $value ) && (int) $value > 0 ) { return gmdate( 'Y-m-d', (int) $value ); } $ts = is_string( $value ) && '' !== $value ? strtotime( $value ) : false; return $ts ? gmdate( 'Y-m-d', $ts ) : ''; } /** * Same, as a MySQL datetime. * * @param mixed $value Timestamp or string. * @return string */ protected function toDateTime( $value ): string { if ( is_numeric( $value ) && (int) $value > 0 ) { return gmdate( 'Y-m-d H:i:s', (int) $value ); } $ts = is_string( $value ) && '' !== $value ? strtotime( $value ) : false; return $ts ? gmdate( 'Y-m-d H:i:s', $ts ) : ''; } /** * A number that may carry a currency symbol or thousands separator. * * @param mixed $value Raw. * @return float */ protected function toNumber( $value ): float { if ( is_numeric( $value ) ) { return (float) $value; } $clean = preg_replace( '/[^0-9.,\-]/', '', (string) $value ); // "1.234,56" vs "1,234.56": the last separator is the decimal point. $last_comma = strrpos( $clean, ',' ); $last_dot = strrpos( $clean, '.' ); if ( false !== $last_comma && ( false === $last_dot || $last_comma > $last_dot ) ) { $clean = str_replace( '.', '', $clean ); $clean = str_replace( ',', '.', $clean ); } else { $clean = str_replace( ',', '', $clean ); } return (float) $clean; } /** * Client name split into first / last. * * @param string $name Full name. * @return array{0:string,1:string} */ protected function splitName( string $name ): array { $name = trim( $name ); if ( '' === $name ) { return [ '', '' ]; } $parts = preg_split( '/\s+/', $name, 2 ); return [ $parts[0], $parts[1] ?? '' ]; } }