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 / CsvImporter.php

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

323 lines 13.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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