| 1 |
<?php |
| 2 |
/** |
| 3 |
* Import clients and invoices from CSV files. |
| 4 |
* |
| 5 |
* @package Easy_Invoice |
| 6 |
* @subpackage Import |
| 7 |
*/ |
| 8 |
|
| 9 |
namespace EasyInvoice\Import; |
| 10 |
|
| 11 |
if ( ! defined( 'ABSPATH' ) ) { |
| 12 |
exit; |
| 13 |
} |
| 14 |
|
| 15 |
/** |
| 16 |
* CSV is the lowest common denominator: FreshBooks, Wave, Zoho, Invoice |
| 17 |
* Ninja and a spreadsheet all produce one. Columns are matched by header |
| 18 |
* name, loosely -- "Client Email", "customer_email" and "Email" all land in |
| 19 |
* the same place -- so most exports work without editing the file. |
| 20 |
* |
| 21 |
* Two files: |
| 22 |
* |
| 23 |
* clients.csv one row per client: name, email, company, address, phone, |
| 24 |
* website, notes |
| 25 |
* invoices.csv one row per line item, the invoice number repeated on each |
| 26 |
* (the way FreshBooks and Zoho export): number, date, |
| 27 |
* due_date, status, client_email, client_name, currency, |
| 28 |
* tax_rate, discount, item, description, quantity, price, |
| 29 |
* amount_paid, paid_date, notes, terms |
| 30 |
* |
| 31 |
* A file with only the header-level columns and no item columns imports each |
| 32 |
* row as one invoice with a single line for the total. |
| 33 |
*/ |
| 34 |
class CsvImporter extends Importer { |
| 35 |
|
| 36 |
/** @var string */ |
| 37 |
private $clients_csv = ''; |
| 38 |
|
| 39 |
/** @var string */ |
| 40 |
private $invoices_csv = ''; |
| 41 |
|
| 42 |
/** Header aliases, lowercase, punctuation removed. */ |
| 43 |
const ALIASES = [ |
| 44 |
'number' => [ 'number', 'invoicenumber', 'invoiceid', 'invoice', 'id', 'no', 'ref', 'reference' ], |
| 45 |
'date' => [ 'date', 'invoicedate', 'issuedate', 'issued', 'dateissued', 'created' ], |
| 46 |
'due_date' => [ 'duedate', 'due', 'datedue', 'paymentdue' ], |
| 47 |
'status' => [ 'status', 'invoicestatus', 'state' ], |
| 48 |
'client_email' => [ 'clientemail', 'customeremail', 'email', 'emailaddress', 'billtoemail' ], |
| 49 |
'client_name' => [ 'clientname', 'customername', 'client', 'customer', 'name', 'billto', 'organization', 'company' ], |
| 50 |
'currency' => [ 'currency', 'currencycode' ], |
| 51 |
'tax_rate' => [ 'taxrate', 'tax', 'taxpercent', 'vatrate', 'vat' ], |
| 52 |
'discount' => [ 'discount', 'discountpercent', 'discountamount' ], |
| 53 |
'item' => [ 'item', 'itemname', 'product', 'service', 'lineitem', 'title' ], |
| 54 |
'description' => [ 'description', 'itemdescription', 'details', 'desc' ], |
| 55 |
'quantity' => [ 'quantity', 'qty', 'hours', 'units' ], |
| 56 |
'price' => [ 'price', 'rate', 'unitprice', 'unitcost', 'amount' ], |
| 57 |
'total' => [ 'total', 'invoicetotal', 'grandtotal', 'amountdue' ], |
| 58 |
'amount_paid' => [ 'amountpaid', 'paid', 'paidamount', 'payments' ], |
| 59 |
'paid_date' => [ 'paiddate', 'datepaid', 'paymentdate' ], |
| 60 |
'notes' => [ 'notes', 'note', 'memo', 'message' ], |
| 61 |
'terms' => [ 'terms', 'termsandconditions', 'paymentterms' ], |
| 62 |
// clients |
| 63 |
'first_name' => [ 'firstname', 'first' ], |
| 64 |
'last_name' => [ 'lastname', 'last', 'surname' ], |
| 65 |
'address' => [ 'address', 'billingaddress', 'street', 'fulladdress' ], |
| 66 |
'phone' => [ 'phone', 'telephone', 'mobile', 'phonenumber' ], |
| 67 |
'website' => [ 'website', 'url', 'web' ], |
| 68 |
]; |
| 69 |
|
| 70 |
/** |
| 71 |
* @param string $clients_csv Path to clients file or ''. |
| 72 |
* @param string $invoices_csv Path to invoices file or ''. |
| 73 |
*/ |
| 74 |
public function __construct( string $clients_csv = '', string $invoices_csv = '' ) { |
| 75 |
$this->clients_csv = $clients_csv; |
| 76 |
$this->invoices_csv = $invoices_csv; |
| 77 |
} |
| 78 |
|
| 79 |
public function source(): string { |
| 80 |
return 'csv'; |
| 81 |
} |
| 82 |
|
| 83 |
public function label(): string { |
| 84 |
return __( 'CSV file', 'easy-invoice' ); |
| 85 |
} |
| 86 |
|
| 87 |
public function available(): bool { |
| 88 |
return true; |
| 89 |
} |
| 90 |
|
| 91 |
public function preview(): array { |
| 92 |
$clients = $this->clients_csv ? count( $this->rows( $this->clients_csv ) ) : 0; |
| 93 |
$invoices = $this->invoices_csv ? count( $this->groupInvoices( $this->rows( $this->invoices_csv ) ) ) : 0; |
| 94 |
return [ 'clients' => $clients, 'invoices' => $invoices, 'quotes' => 0, 'payments' => 0, 'already' => 0 ]; |
| 95 |
} |
| 96 |
|
| 97 |
public function run(): array { |
| 98 |
if ( $this->clients_csv ) { |
| 99 |
foreach ( $this->rows( $this->clients_csv ) as $i => $r ) { |
| 100 |
$name = (string) ( $r['client_name'] ?? '' ); |
| 101 |
list( $first, $last ) = $this->splitName( $name ); |
| 102 |
$this->findOrCreateClient( [ |
| 103 |
'source_id' => 'c:' . md5( strtolower( (string) ( $r['client_email'] ?? $name ?: $i ) ) ), |
| 104 |
'email' => (string) ( $r['client_email'] ?? '' ), |
| 105 |
'first_name' => (string) ( $r['first_name'] ?? $first ), |
| 106 |
'last_name' => (string) ( $r['last_name'] ?? $last ), |
| 107 |
'business' => (string) ( $r['company'] ?? ( isset( $r['first_name'] ) ? $name : '' ) ), |
| 108 |
'address' => (string) ( $r['address'] ?? '' ), |
| 109 |
'phone' => (string) ( $r['phone'] ?? '' ), |
| 110 |
'website' => (string) ( $r['website'] ?? '' ), |
| 111 |
'extra_info' => (string) ( $r['notes'] ?? '' ), |
| 112 |
] ); |
| 113 |
} |
| 114 |
} |
| 115 |
|
| 116 |
if ( $this->invoices_csv ) { |
| 117 |
foreach ( $this->groupInvoices( $this->rows( $this->invoices_csv ) ) as $number => $rows ) { |
| 118 |
$this->importInvoice( (string) $number, $rows ); |
| 119 |
} |
| 120 |
} |
| 121 |
|
| 122 |
return $this->report(); |
| 123 |
} |
| 124 |
|
| 125 |
/** |
| 126 |
* One invoice from its rows (one per line item). |
| 127 |
* |
| 128 |
* @param string $number Invoice number. |
| 129 |
* @param array $rows Mapped rows. |
| 130 |
* @return void |
| 131 |
*/ |
| 132 |
private function importInvoice( string $number, array $rows ): void { |
| 133 |
$h = $rows[0]; |
| 134 |
$email = sanitize_email( (string) ( $h['client_email'] ?? '' ) ); |
| 135 |
$name = (string) ( $h['client_name'] ?? '' ); |
| 136 |
$client_id = 0; |
| 137 |
if ( '' !== $email || '' !== $name ) { |
| 138 |
list( $first, $last ) = $this->splitName( $name ); |
| 139 |
$client_id = $this->findOrCreateClient( [ |
| 140 |
'source_id' => 'c:' . md5( strtolower( $email ?: $name ) ), |
| 141 |
'email' => $email, |
| 142 |
'first_name' => $first, |
| 143 |
'last_name' => $last, |
| 144 |
'business' => $name, |
| 145 |
] ); |
| 146 |
} |
| 147 |
|
| 148 |
$items = []; |
| 149 |
foreach ( $rows as $r ) { |
| 150 |
$item_name = trim( (string) ( $r['item'] ?? '' ) ); |
| 151 |
$desc = trim( (string) ( $r['description'] ?? '' ) ); |
| 152 |
if ( '' === $item_name && '' === $desc ) { |
| 153 |
continue; |
| 154 |
} |
| 155 |
$items[] = [ |
| 156 |
'name' => '' !== $item_name ? $item_name : strtok( $desc, "\n" ), |
| 157 |
'description' => '' !== $item_name ? $desc : '', |
| 158 |
'quantity' => isset( $r['quantity'] ) && '' !== $r['quantity'] ? $this->toNumber( $r['quantity'] ) : 1, |
| 159 |
'price' => $this->toNumber( $r['price'] ?? 0 ), |
| 160 |
'taxable' => true, |
| 161 |
]; |
| 162 |
} |
| 163 |
if ( empty( $items ) ) { |
| 164 |
$items[] = [ |
| 165 |
'name' => (string) ( $h['description'] ?? $h['notes'] ?? '' ) ?: sprintf( /* translators: %s: invoice number. */ __( 'Invoice %s', 'easy-invoice' ), $number ), |
| 166 |
'quantity' => 1, |
| 167 |
'price' => $this->toNumber( $h['total'] ?? $h['price'] ?? 0 ), |
| 168 |
'taxable' => false, |
| 169 |
]; |
| 170 |
} |
| 171 |
|
| 172 |
$status_raw = strtolower( trim( (string) ( $h['status'] ?? '' ) ) ); |
| 173 |
$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' ]; |
| 174 |
$status = $status_map[ $status_raw ] ?? 'available'; |
| 175 |
|
| 176 |
$invoice_id = $this->createInvoice( [ |
| 177 |
'source_id' => 'i:' . $number, |
| 178 |
'title' => (string) ( $h['description'] ?? '' ) ?: sprintf( /* translators: %s: invoice number. */ __( 'Invoice %s', 'easy-invoice' ), $number ), |
| 179 |
'number' => $number, |
| 180 |
'status' => $status, |
| 181 |
'issue_date' => $this->toDate( $h['date'] ?? '' ) ?: current_time('Y-m-d'), |
| 182 |
'due_date' => $this->toDate( $h['due_date'] ?? '' ), |
| 183 |
'created' => $this->toDateTime( $h['date'] ?? '' ), |
| 184 |
'client_id' => $client_id, |
| 185 |
'customer_name' => $name, |
| 186 |
'customer_email' => $email, |
| 187 |
'items' => $items, |
| 188 |
'tax_rate' => $this->toNumber( $h['tax_rate'] ?? 0 ), |
| 189 |
'discount_type' => false !== strpos( (string) ( $h['discount'] ?? '' ), '%' ) ? 'percentage' : 'fixed', |
| 190 |
'discount_value' => $this->toNumber( $h['discount'] ?? 0 ), |
| 191 |
'currency_code' => strtoupper( trim( (string) ( $h['currency'] ?? '' ) ) ), |
| 192 |
'notes' => (string) ( $h['notes'] ?? '' ), |
| 193 |
'terms' => (string) ( $h['terms'] ?? '' ), |
| 194 |
] ); |
| 195 |
|
| 196 |
if ( $invoice_id <= 0 ) { |
| 197 |
return; |
| 198 |
} |
| 199 |
$paid = $this->toNumber( $h['amount_paid'] ?? 0 ); |
| 200 |
if ( $paid <= 0 && 'paid' === $status ) { |
| 201 |
$inv = new \EasyInvoice\Models\Invoice( get_post( $invoice_id ) ); |
| 202 |
$paid = (float) $inv->getTotal(); |
| 203 |
} |
| 204 |
if ( $paid > 0 ) { |
| 205 |
$this->createPayment( $invoice_id, [ |
| 206 |
'source_id' => 'p:' . $number, |
| 207 |
'amount' => $paid, |
| 208 |
'date' => $this->toDateTime( $h['paid_date'] ?? '' ) ?: ( $this->toDateTime( $h['date'] ?? '' ) ?: current_time( 'mysql' ) ), |
| 209 |
'method' => 'imported', |
| 210 |
'status' => 'completed', |
| 211 |
'notes' => __( 'Imported from CSV', 'easy-invoice' ), |
| 212 |
] ); |
| 213 |
} |
| 214 |
} |
| 215 |
|
| 216 |
/** |
| 217 |
* Rows keyed by canonical column names. |
| 218 |
* |
| 219 |
* @param string $path File. |
| 220 |
* @return array<int,array<string,string>> |
| 221 |
*/ |
| 222 |
private function rows( string $path ): array { |
| 223 |
if ( ! is_readable( $path ) ) { |
| 224 |
$this->notes[] = sprintf( 'Cannot read %s.', basename( $path ) ); |
| 225 |
return []; |
| 226 |
} |
| 227 |
$fh = fopen( $path, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- streaming a large upload row by row. |
| 228 |
if ( ! $fh ) { |
| 229 |
return []; |
| 230 |
} |
| 231 |
$first = fgets( $fh ); |
| 232 |
rewind( $fh ); |
| 233 |
$delimiter = ( substr_count( (string) $first, ';' ) > substr_count( (string) $first, ',' ) ) ? ';' : ( substr_count( (string) $first, "\t" ) > substr_count( (string) $first, ',' ) ? "\t" : ',' ); |
| 234 |
|
| 235 |
$header = fgetcsv( $fh, 0, $delimiter, '"', '\\' ); |
| 236 |
if ( ! is_array( $header ) ) { |
| 237 |
fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions |
| 238 |
return []; |
| 239 |
} |
| 240 |
// Strip a BOM from the first header cell. |
| 241 |
$header[0] = preg_replace( '/^\xEF\xBB\xBF/', '', (string) $header[0] ); |
| 242 |
$map = []; |
| 243 |
foreach ( $header as $i => $col ) { |
| 244 |
$key = $this->canonical( (string) $col ); |
| 245 |
if ( '' !== $key && ! isset( $map[ $key ] ) ) { |
| 246 |
$map[ $key ] = $i; |
| 247 |
} |
| 248 |
} |
| 249 |
$rows = []; |
| 250 |
while ( ( $line = fgetcsv( $fh, 0, $delimiter, '"', '\\' ) ) !== false ) { |
| 251 |
if ( 1 === count( $line ) && null === $line[0] ) { |
| 252 |
continue; |
| 253 |
} |
| 254 |
$row = []; |
| 255 |
foreach ( $map as $key => $i ) { |
| 256 |
$row[ $key ] = isset( $line[ $i ] ) ? trim( (string) $line[ $i ] ) : ''; |
| 257 |
} |
| 258 |
if ( '' === implode( '', $row ) ) { |
| 259 |
continue; |
| 260 |
} |
| 261 |
$rows[] = $row; |
| 262 |
} |
| 263 |
fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions |
| 264 |
return $rows; |
| 265 |
} |
| 266 |
|
| 267 |
/** |
| 268 |
* Group line-item rows by invoice number. |
| 269 |
* |
| 270 |
* @param array $rows Mapped rows. |
| 271 |
* @return array<string,array> |
| 272 |
*/ |
| 273 |
private function groupInvoices( array $rows ): array { |
| 274 |
$groups = []; |
| 275 |
$auto = 0; |
| 276 |
foreach ( $rows as $r ) { |
| 277 |
$n = trim( (string) ( $r['number'] ?? '' ) ); |
| 278 |
if ( '' === $n ) { |
| 279 |
$n = 'CSV-' . ( ++$auto ); |
| 280 |
} |
| 281 |
$groups[ $n ][] = $r; |
| 282 |
} |
| 283 |
return $groups; |
| 284 |
} |
| 285 |
|
| 286 |
/** |
| 287 |
* Header text → canonical key, '' when unknown. |
| 288 |
* |
| 289 |
* @param string $col Header cell. |
| 290 |
* @return string |
| 291 |
*/ |
| 292 |
private function canonical( string $col ): string { |
| 293 |
$norm = preg_replace( '/[^a-z0-9]/', '', strtolower( $col ) ); |
| 294 |
if ( '' === $norm ) { |
| 295 |
return ''; |
| 296 |
} |
| 297 |
if ( 'company' === $norm || 'business' === $norm || 'businessname' === $norm || 'companyname' === $norm ) { |
| 298 |
return 'company'; |
| 299 |
} |
| 300 |
foreach ( self::ALIASES as $key => $aliases ) { |
| 301 |
if ( in_array( $norm, $aliases, true ) ) { |
| 302 |
return $key; |
| 303 |
} |
| 304 |
} |
| 305 |
return ''; |
| 306 |
} |
| 307 |
|
| 308 |
/** |
| 309 |
* Header rows for the template files. |
| 310 |
* |
| 311 |
* @param string $which 'clients' or 'invoices'. |
| 312 |
* @return string CSV text. |
| 313 |
*/ |
| 314 |
public static function template( string $which ): string { |
| 315 |
if ( 'clients' === $which ) { |
| 316 |
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"; |
| 317 |
} |
| 318 |
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" |
| 319 |
. "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" |
| 320 |
. "INV-1001,2026-03-01,2026-03-31,paid,jane@example.com,\"Doe Studio\",EUR,19,0,\"Hosting\",\"March\",1,50,,,,\n"; |
| 321 |
} |
| 322 |
} |
| 323 |
|