PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
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
← All changes | includes/Repositories/ClientRepository.php +166 -118 2.2.02.4.0 View file →
@@ -20,8 +20,15 @@
20 20 *
21 21 * Handles data access for client objects using WordPress users.
22 22 */
23 23 class ClientRepository implements ClientRepositoryInterface {
24 + /** @var string Why the last create() returned null, in words for the form. */
25 + protected $last_error = '';
26 +
27 + public function getLastError(): string {
28 + return (string) $this->last_error;
29 + }
30 +
24 31
25 32 /**
26 33 * Find a client by ID
27 34 *
@@ -99,19 +106,46 @@
99 106 'role' => 'customer',
100 107 ];
101 108
102 109 $user_id = wp_insert_user($user_data);
103 -
110 +
104 111 if (is_wp_error($user_id)) {
112 + // Keep the reason for the caller: "that email is already registered"
113 + // is something the person at the form can act on, "failed" is not.
114 + $this->last_error = $user_id->get_error_message();
105 115 return null;
106 116 }
107 -
117 + $this->last_error = '';
118 +
119 + // Deny backend access on new clients via a per-user capability
120 + // override. The user keeps the `customer` role (so Pro Client
121 + // Portal's `in_array('customer', $user->roles)` check still
122 + // recognises them) but `read` is explicitly set to false at the
123 + // user level, which beats the role's `read => true` when
124 + // WP_User::has_cap() resolves the merged capability map. Result:
125 + // `current_user_can('read')` is false, /wp-admin/ is blocked,
126 + // login still works (auth itself is cap-free).
127 + //
128 + // Per-user override only — existing customer-role users elsewhere
129 + // on the site (including pre-existing EI clients and WooCommerce
130 + // customers) are NOT affected; only the user we just created.
131 + $wp_user = new \WP_User($user_id);
132 + $wp_user->add_cap('read', false);
133 +
108 134 // Create client model
109 135 $client = new Client($user_id);
110 -
136 +
111 137 // Set the client data
112 138 $this->setClientData($client, $data);
113 -
139 +
140 + /**
141 + * Fires once a client (and their WordPress user) has been created.
142 + *
143 + * @param Client $client The new client; its id is the user id.
144 + * @param array $data The submitted client data.
145 + */
146 + do_action('easy_invoice_client_created', $client, $data);
147 +
114 148 return $client;
115 149 }
116 150
117 151 /**
@@ -200,21 +234,28 @@
200 234 foreach ($posts as $post) {
201 235 wp_delete_post($post->ID, true);
202 236 }
203 237
204 - // Delete all payments associated with this client
205 - $payments = $wpdb->get_col($wpdb->prepare(
206 - "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'easy_payment' AND ID IN (
207 - SELECT post_id FROM {$wpdb->postmeta}
208 - WHERE meta_key = '_easy_payment_client_id' AND meta_value = %d
209 - )",
210 - $id
211 - ));
238 + // Payments are deliberately NOT deleted here.
239 + //
240 + // There used to be a query for post type 'easy_payment' on meta
241 + // '_easy_payment_client_id'. Neither exists — payments are
242 + // 'easy_invoice_payment' and record '_invoice_id', with no client id at
243 + // all — so it matched nothing on every run. Deleting a client has never
244 + // removed a payment record.
245 + //
246 + // The query is gone rather than corrected. Resolving payments properly
247 + // (through ClientLedger, which knows the real relationship) would make
248 + // this destroy records it has never touched, on a path a user reaches by
249 + // clicking Delete on a client. Widening a destructive operation as a
250 + // side effect of fixing a broken query is not a safe trade: a payment is
251 + // the evidence money changed hands, and the same reasoning that stops
252 + // InvoiceRetention deleting an issued invoice applies to it.
253 + //
254 + // What changed is that the confirmation dialog now counts payments
255 + // correctly (see EasyInvoiceAjax::handleDeleteClient), so a merchant is
256 + // told what will be left behind instead of being shown zero.
212 257
213 - foreach ($payments as $payment_id) {
214 - wp_delete_post($payment_id, true);
215 - }
216 -
217 258 // Delete the WordPress user (this will also delete all user meta)
218 259 $result = wp_delete_user($id);
219 260
220 261 // Return the result
@@ -262,123 +303,130 @@
262 303 return $this->all($args);
263 304 }
264 305
265 306 /**
266 - * Search clients by name, email, or company
307 + * Search clients by name, email, company, or phone.
267 308 *
309 + * Matches against:
310 + * - User table columns: display_name, user_login, user_email,
311 + * user_nicename, user_url (these are what WP_User_Query's
312 + * `search` + `search_columns` can actually index).
313 + * - WP standard meta: first_name, last_name, nickname.
314 + * - WooCommerce billing meta: billing_first_name, billing_last_name,
315 + * billing_email, billing_company, billing_phone — so customers
316 + * imported via WooCommerce can be found by their billing details.
317 + * - Easy Invoice's own meta: first/last/email/business name.
318 + *
319 + * The previous implementation gated every query on a meta_query
320 + * requiring `_easy_invoice_client_business_client_name` OR
321 + * `_easy_invoice_client_email` to EXIST, which silently excluded
322 + * every WooCommerce customer (they don't have those EI-specific
323 + * keys until they've been edited inside Easy Invoice). On stores
324 + * with hundreds of imported WC customers, search returned nothing
325 + * even though the unfiltered list showed every client.
326 + *
268 327 * @param string $query The search query
269 328 * @return array Array of Client models
270 329 */
271 330 public function search($query) {
272 - $clients = [];
273 - $query = trim($query);
274 -
275 - if (empty($query)) {
276 - return $this->all();
331 + $query = trim((string) $query);
332 +
333 + if ($query === '') {
334 + // The picker opens with an empty query to show "some" clients. Build
335 + // only the 50 it can show instead of a model for every user on the site.
336 + $recent = new WP_User_Query([
337 + 'number' => 50,
338 + 'orderby' => 'display_name',
339 + 'order' => 'ASC',
340 + 'role__not_in' => ['Administrator'],
341 + ]);
342 + $clients = [];
343 + foreach ($recent->get_results() as $user) {
344 + $client = $this->createClientFromUser($user);
345 + if ($client) {
346 + $clients[] = $client;
347 + }
348 + }
349 + return $clients;
277 350 }
278 -
279 - // Search by display name, first name, last name, or email
280 - $name_args = [
281 - 'search' => '*' . $query . '*',
282 - 'search_columns' => ['display_name', 'first_name', 'last_name', 'user_email'],
283 - 'meta_query' => [
284 - 'relation' => 'OR',
285 - [
286 - 'key' => ClientFields::BUSINESS_CLIENT_NAME,
287 - 'compare' => 'EXISTS',
288 - ],
289 - [
290 - 'key' => ClientFields::EMAIL,
291 - 'compare' => 'EXISTS',
292 - ],
293 - ],
294 - ];
295 -
296 - $name_query = new WP_User_Query($name_args);
297 - foreach ($name_query->get_results() as $user) {
298 - $clients[] = $this->createClientFromUser($user);
299 - }
300 -
301 - // Search by business client name (meta field)
302 - $business_args = [
303 - 'meta_query' => [
304 - 'relation' => 'AND',
305 - [
306 - 'key' => ClientFields::BUSINESS_CLIENT_NAME,
307 - 'value' => $query,
308 - 'compare' => 'LIKE',
309 - ],
310 - [
311 - 'relation' => 'OR',
312 - [
313 - 'key' => ClientFields::BUSINESS_CLIENT_NAME,
314 - 'compare' => 'EXISTS',
315 - ],
316 - [
317 - 'key' => ClientFields::EMAIL,
318 - 'compare' => 'EXISTS',
319 - ],
320 - ],
321 - ],
322 - ];
323 -
324 - $business_query = new WP_User_Query($business_args);
325 - foreach ($business_query->get_results() as $user) {
326 - // Check if this client is already in the results
327 - $exists = false;
328 - foreach ($clients as $existing_client) {
329 - if ($existing_client->getId() === $user->ID) {
330 - $exists = true;
331 - break;
332 - }
351 +
352 + $clients = [];
353 + $seen_ids = [];
354 + $like = '*' . $query . '*';
355 +
356 + // ── 1) User table columns ──────────────────────────────────────
357 + // These are the only columns WP_User_Query's `search_columns`
358 + // accepts; passing `first_name` etc. is silently ignored, so we
359 + // pick those up via meta in step 2 below.
360 + $user_query = new WP_User_Query([
361 + 'number' => 50,
362 + 'orderby' => 'display_name',
363 + 'order' => 'ASC',
364 + 'search' => $like,
365 + 'search_columns' => ['user_login', 'user_email', 'user_nicename', 'display_name', 'user_url'],
366 + 'role__not_in' => ['Administrator'],
367 + ]);
368 + foreach ($user_query->get_results() as $user) {
369 + if (isset($seen_ids[$user->ID])) {
370 + continue;
333 371 }
334 -
335 - if (!$exists) {
336 - $clients[] = $this->createClientFromUser($user);
372 + $seen_ids[$user->ID] = true;
373 + $client = $this->createClientFromUser($user);
374 + if ($client) {
375 + $clients[] = $client;
337 376 }
338 377 }
339 -
340 - // Search by email (meta field)
341 - $email_args = [
342 - 'meta_query' => [
343 - 'relation' => 'AND',
344 - [
345 - 'key' => ClientFields::EMAIL,
346 - 'value' => $query,
347 - 'compare' => 'LIKE',
348 - ],
349 - [
350 - 'relation' => 'OR',
351 - [
352 - 'key' => ClientFields::BUSINESS_CLIENT_NAME,
353 - 'compare' => 'EXISTS',
354 - ],
355 - [
356 - 'key' => ClientFields::EMAIL,
357 - 'compare' => 'EXISTS',
358 - ],
359 - ],
360 - ],
378 +
379 + // ── 2) User-meta columns ───────────────────────────────────────
380 + // Search across every meta key we know a client's name / email /
381 + // company / phone could be stored under. WP-standard, WooCommerce
382 + // billing_*, and Easy Invoice's own keys are all OR'd together so
383 + // the user only has to match ONE for a row to qualify.
384 + $meta_keys = [
385 + // WP standard
386 + 'first_name', 'last_name', 'nickname',
387 + // WooCommerce billing — covers imported store customers
388 + 'billing_first_name', 'billing_last_name', 'billing_email',
389 + 'billing_company', 'billing_phone',
390 + // Easy Invoice's own — note PHONE here too so EI-native clients
391 + // (whose phone lives in _easy_invoice_client_phone, not billing_*)
392 + // are searchable by phone number on equal footing with WC customers.
393 + ClientFields::FIRST_NAME, ClientFields::LAST_NAME,
394 + ClientFields::EMAIL, ClientFields::BUSINESS_CLIENT_NAME,
395 + ClientFields::PHONE,
361 396 ];
362 -
363 - $email_query = new WP_User_Query($email_args);
364 - foreach ($email_query->get_results() as $user) {
365 - // Check if this client is already in the results
366 - $exists = false;
367 - foreach ($clients as $existing_client) {
368 - if ($existing_client->getId() === $user->ID) {
369 - $exists = true;
370 - break;
371 - }
397 +
398 + $meta_query = ['relation' => 'OR'];
399 + foreach ($meta_keys as $key) {
400 + $meta_query[] = [
401 + 'key' => $key,
402 + 'value' => $query,
403 + 'compare' => 'LIKE',
404 + ];
405 + }
406 +
407 + $meta_user_query = new WP_User_Query([
408 + 'number' => 50,
409 + 'orderby' => 'display_name',
410 + 'order' => 'ASC',
411 + 'role__not_in' => ['Administrator'],
412 + 'meta_query' => $meta_query,
413 + ]);
414 + foreach ($meta_user_query->get_results() as $user) {
415 + if (isset($seen_ids[$user->ID])) {
416 + continue;
372 417 }
373 -
374 - if (!$exists) {
375 - $clients[] = $this->createClientFromUser($user);
418 + $seen_ids[$user->ID] = true;
419 + $client = $this->createClientFromUser($user);
420 + if ($client) {
421 + $clients[] = $client;
376 422 }
377 423 }
378 -
379 - // Limit results to 10 to avoid performance issues
380 - return array_slice($clients, 0, 10);
424 +
425 + // Cap the dropdown at 50 results so the autocomplete stays
426 + // responsive on stores with thousands of customers. The user
427 + // can refine the query to narrow further.
428 + return array_slice($clients, 0, 50);
381 429 }
382 430
383 431 /**
384 432 * Create a client model from a WordPress user