# easy-invoice/2.4.0/includes/Import/Importer.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.0. 491 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.0/code/includes/Import/Importer.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.0/raw/includes/Import/Importer.php
- Modified: 2026-09-15T12:31:20+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/easy-invoice/2.4.0/code/includes/Import/Importer.php#L10-L20`.

```php
<?php
/**
 * Base for bringing invoices in from another product.
 *
 * @package Easy_Invoice
 * @subpackage Import
 */

namespace EasyInvoice\Import;

use EasyInvoice\Constants\ClientFields;
use EasyInvoice\Constants\PostTypes;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Why an importer
 * ---------------
 * Nobody moves two hundred clients and three years of invoices by hand, so
 * without an importer the plugin can only win people who have never invoiced
 * before. Sliced Invoices and Sprout Invoices -- the two plugins ahead of us
 * in the directory -- keep their data as post types on the same WordPress
 * install, which means we can read it directly, with no export step. CSV
 * covers everything else (FreshBooks, Wave, Zoho and spreadsheets).
 *
 * Every importer works the same way: `preview()` counts what it would bring
 * in, `run()` brings it in. Records remember where they came from
 * (`_easy_invoice_import_source` / `_easy_invoice_import_source_id`), so running
 * an import twice adds nothing, and a client that already exists here (matched
 * by email) is reused rather than duplicated. Imported documents keep their
 * original numbers and dates; nothing is renumbered.
 */
abstract class Importer {

    const META_SOURCE    = '_easy_invoice_import_source';
    const META_SOURCE_ID = '_easy_invoice_import_source_id';

    /** @var array<string,int> */
    protected $counts = [ 'clients' => 0, 'clients_matched' => 0, 'invoices' => 0, 'quotes' => 0, 'payments' => 0, 'skipped' => 0 ];

    /** @var string[] */
    protected $notes = [];

    /** @var array<string,int> 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<string,int>
     */
    abstract public function preview(): array;

    /**
     * Do the import.
     *
     * @return array{counts:array<string,int>,notes:string[]}
     */
    abstract public function run(): array;

    /**
     * Report shape shared by every importer.
     *
     * @return array{counts:array<string,int>,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] ?? '' ];
    }
}

```
