[ 'number', 'invoicenumber', 'invoiceid', 'invoice', 'id', 'no', 'ref', 'reference' ], 'date' => [ 'date', 'invoicedate', 'issuedate', 'issued', 'dateissued', 'created' ], 'due_date' => [ 'duedate', 'due', 'datedue', 'paymentdue' ], 'status' => [ 'status', 'invoicestatus', 'state' ], 'client_email' => [ 'clientemail', 'customeremail', 'email', 'emailaddress', 'billtoemail' ], 'client_name' => [ 'clientname', 'customername', 'client', 'customer', 'name', 'billto', 'organization', 'company' ], 'currency' => [ 'currency', 'currencycode' ], 'tax_rate' => [ 'taxrate', 'tax', 'taxpercent', 'vatrate', 'vat' ], 'discount' => [ 'discount', 'discountpercent', 'discountamount' ], 'item' => [ 'item', 'itemname', 'product', 'service', 'lineitem', 'title' ], 'description' => [ 'description', 'itemdescription', 'details', 'desc' ], 'quantity' => [ 'quantity', 'qty', 'hours', 'units' ], 'price' => [ 'price', 'rate', 'unitprice', 'unitcost', 'amount' ], 'total' => [ 'total', 'invoicetotal', 'grandtotal', 'amountdue' ], 'amount_paid' => [ 'amountpaid', 'paid', 'paidamount', 'payments' ], 'paid_date' => [ 'paiddate', 'datepaid', 'paymentdate' ], 'notes' => [ 'notes', 'note', 'memo', 'message' ], 'terms' => [ 'terms', 'termsandconditions', 'paymentterms' ], // clients 'first_name' => [ 'firstname', 'first' ], 'last_name' => [ 'lastname', 'last', 'surname' ], 'address' => [ 'address', 'billingaddress', 'street', 'fulladdress' ], 'phone' => [ 'phone', 'telephone', 'mobile', 'phonenumber' ], 'website' => [ 'website', 'url', 'web' ], ]; /** * @param string $clients_csv Path to clients file or ''. * @param string $invoices_csv Path to invoices file or ''. */ public function __construct( string $clients_csv = '', string $invoices_csv = '' ) { $this->clients_csv = $clients_csv; $this->invoices_csv = $invoices_csv; } public function source(): string { return 'csv'; } public function label(): string { return __( 'CSV file', 'easy-invoice' ); } public function available(): bool { return true; } public function preview(): array { $clients = $this->clients_csv ? count( $this->rows( $this->clients_csv ) ) : 0; $invoices = $this->invoices_csv ? count( $this->groupInvoices( $this->rows( $this->invoices_csv ) ) ) : 0; return [ 'clients' => $clients, 'invoices' => $invoices, 'quotes' => 0, 'payments' => 0, 'already' => 0 ]; } public function run(): array { if ( $this->clients_csv ) { foreach ( $this->rows( $this->clients_csv ) as $i => $r ) { $name = (string) ( $r['client_name'] ?? '' ); list( $first, $last ) = $this->splitName( $name ); $this->findOrCreateClient( [ 'source_id' => 'c:' . md5( strtolower( (string) ( $r['client_email'] ?? $name ?: $i ) ) ), 'email' => (string) ( $r['client_email'] ?? '' ), 'first_name' => (string) ( $r['first_name'] ?? $first ), 'last_name' => (string) ( $r['last_name'] ?? $last ), 'business' => (string) ( $r['company'] ?? ( isset( $r['first_name'] ) ? $name : '' ) ), 'address' => (string) ( $r['address'] ?? '' ), 'phone' => (string) ( $r['phone'] ?? '' ), 'website' => (string) ( $r['website'] ?? '' ), 'extra_info' => (string) ( $r['notes'] ?? '' ), ] ); } } if ( $this->invoices_csv ) { foreach ( $this->groupInvoices( $this->rows( $this->invoices_csv ) ) as $number => $rows ) { $this->importInvoice( (string) $number, $rows ); } } return $this->report(); } /** * One invoice from its rows (one per line item). * * @param string $number Invoice number. * @param array $rows Mapped rows. * @return void */ private function importInvoice( string $number, array $rows ): void { $h = $rows[0]; $email = sanitize_email( (string) ( $h['client_email'] ?? '' ) ); $name = (string) ( $h['client_name'] ?? '' ); $client_id = 0; if ( '' !== $email || '' !== $name ) { list( $first, $last ) = $this->splitName( $name ); $client_id = $this->findOrCreateClient( [ 'source_id' => 'c:' . md5( strtolower( $email ?: $name ) ), 'email' => $email, 'first_name' => $first, 'last_name' => $last, 'business' => $name, ] ); } $items = []; foreach ( $rows as $r ) { $item_name = trim( (string) ( $r['item'] ?? '' ) ); $desc = trim( (string) ( $r['description'] ?? '' ) ); if ( '' === $item_name && '' === $desc ) { continue; } $items[] = [ 'name' => '' !== $item_name ? $item_name : strtok( $desc, "\n" ), 'description' => '' !== $item_name ? $desc : '', 'quantity' => isset( $r['quantity'] ) && '' !== $r['quantity'] ? $this->toNumber( $r['quantity'] ) : 1, 'price' => $this->toNumber( $r['price'] ?? 0 ), 'taxable' => true, ]; } if ( empty( $items ) ) { $items[] = [ 'name' => (string) ( $h['description'] ?? $h['notes'] ?? '' ) ?: sprintf( /* translators: %s: invoice number. */ __( 'Invoice %s', 'easy-invoice' ), $number ), 'quantity' => 1, 'price' => $this->toNumber( $h['total'] ?? $h['price'] ?? 0 ), 'taxable' => false, ]; } $status_raw = strtolower( trim( (string) ( $h['status'] ?? '' ) ) ); $status_map = [ 'paid' => 'paid', 'complete' => 'paid', 'completed' => 'paid', 'draft' => 'draft', 'sent' => 'available', 'unpaid' => 'available', 'open' => 'available', 'outstanding' => 'available', 'overdue' => 'available', 'partial' => 'available', 'void' => 'cancelled', 'cancelled' => 'cancelled', 'canceled' => 'cancelled' ]; $status = $status_map[ $status_raw ] ?? 'available'; $invoice_id = $this->createInvoice( [ 'source_id' => 'i:' . $number, 'title' => (string) ( $h['description'] ?? '' ) ?: sprintf( /* translators: %s: invoice number. */ __( 'Invoice %s', 'easy-invoice' ), $number ), 'number' => $number, 'status' => $status, 'issue_date' => $this->toDate( $h['date'] ?? '' ) ?: current_time('Y-m-d'), 'due_date' => $this->toDate( $h['due_date'] ?? '' ), 'created' => $this->toDateTime( $h['date'] ?? '' ), 'client_id' => $client_id, 'customer_name' => $name, 'customer_email' => $email, 'items' => $items, 'tax_rate' => $this->toNumber( $h['tax_rate'] ?? 0 ), 'discount_type' => false !== strpos( (string) ( $h['discount'] ?? '' ), '%' ) ? 'percentage' : 'fixed', 'discount_value' => $this->toNumber( $h['discount'] ?? 0 ), 'currency_code' => strtoupper( trim( (string) ( $h['currency'] ?? '' ) ) ), 'notes' => (string) ( $h['notes'] ?? '' ), 'terms' => (string) ( $h['terms'] ?? '' ), ] ); if ( $invoice_id <= 0 ) { return; } $paid = $this->toNumber( $h['amount_paid'] ?? 0 ); if ( $paid <= 0 && 'paid' === $status ) { $inv = new \EasyInvoice\Models\Invoice( get_post( $invoice_id ) ); $paid = (float) $inv->getTotal(); } if ( $paid > 0 ) { $this->createPayment( $invoice_id, [ 'source_id' => 'p:' . $number, 'amount' => $paid, 'date' => $this->toDateTime( $h['paid_date'] ?? '' ) ?: ( $this->toDateTime( $h['date'] ?? '' ) ?: current_time( 'mysql' ) ), 'method' => 'imported', 'status' => 'completed', 'notes' => __( 'Imported from CSV', 'easy-invoice' ), ] ); } } /** * Rows keyed by canonical column names. * * @param string $path File. * @return array> */ private function rows( string $path ): array { if ( ! is_readable( $path ) ) { $this->notes[] = sprintf( 'Cannot read %s.', basename( $path ) ); return []; } $fh = fopen( $path, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- streaming a large upload row by row. if ( ! $fh ) { return []; } $first = fgets( $fh ); rewind( $fh ); $delimiter = ( substr_count( (string) $first, ';' ) > substr_count( (string) $first, ',' ) ) ? ';' : ( substr_count( (string) $first, "\t" ) > substr_count( (string) $first, ',' ) ? "\t" : ',' ); $header = fgetcsv( $fh, 0, $delimiter, '"', '\\' ); if ( ! is_array( $header ) ) { fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions return []; } // Strip a BOM from the first header cell. $header[0] = preg_replace( '/^\xEF\xBB\xBF/', '', (string) $header[0] ); $map = []; foreach ( $header as $i => $col ) { $key = $this->canonical( (string) $col ); if ( '' !== $key && ! isset( $map[ $key ] ) ) { $map[ $key ] = $i; } } $rows = []; while ( ( $line = fgetcsv( $fh, 0, $delimiter, '"', '\\' ) ) !== false ) { if ( 1 === count( $line ) && null === $line[0] ) { continue; } $row = []; foreach ( $map as $key => $i ) { $row[ $key ] = isset( $line[ $i ] ) ? trim( (string) $line[ $i ] ) : ''; } if ( '' === implode( '', $row ) ) { continue; } $rows[] = $row; } fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions return $rows; } /** * Group line-item rows by invoice number. * * @param array $rows Mapped rows. * @return array */ private function groupInvoices( array $rows ): array { $groups = []; $auto = 0; foreach ( $rows as $r ) { $n = trim( (string) ( $r['number'] ?? '' ) ); if ( '' === $n ) { $n = 'CSV-' . ( ++$auto ); } $groups[ $n ][] = $r; } return $groups; } /** * Header text → canonical key, '' when unknown. * * @param string $col Header cell. * @return string */ private function canonical( string $col ): string { $norm = preg_replace( '/[^a-z0-9]/', '', strtolower( $col ) ); if ( '' === $norm ) { return ''; } if ( 'company' === $norm || 'business' === $norm || 'businessname' === $norm || 'companyname' === $norm ) { return 'company'; } foreach ( self::ALIASES as $key => $aliases ) { if ( in_array( $norm, $aliases, true ) ) { return $key; } } return ''; } /** * Header rows for the template files. * * @param string $which 'clients' or 'invoices'. * @return string CSV text. */ public static function template( string $which ): string { if ( 'clients' === $which ) { return "name,email,company,address,phone,website,notes\n\"Jane Doe\",jane@example.com,\"Doe Studio\",\"12 Bridge Street, Berlin\",+49 30 1234567,https://doe.example,\"Prefers invoices on the 1st\"\n"; } return "number,date,due_date,status,client_email,client_name,currency,tax_rate,discount,item,description,quantity,price,amount_paid,paid_date,notes,terms\n" . "INV-1001,2026-03-01,2026-03-31,paid,jane@example.com,\"Doe Studio\",EUR,19,0,\"Design retainer\",\"March\",1,1200,1428,2026-03-20,\"Thanks!\",\"Net 30\"\n" . "INV-1001,2026-03-01,2026-03-31,paid,jane@example.com,\"Doe Studio\",EUR,19,0,\"Hosting\",\"March\",1,50,,,,\n"; } }