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
easy-invoice / includes / Services / CreditNote.php

CreditNote.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.0, at includes/Services/CreditNote.php

440 lines 17.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Credit notes — the lawful way to undo an invoice.
4 *
5 * @package Easy_Invoice
6 * @subpackage Services
7 */
8
9 namespace EasyInvoice\Services;
10
11 use EasyInvoice\Constants\PostTypes;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Issues credit notes against invoices.
19 *
20 * Why this exists
21 * ---------------
22 * An issued invoice cannot be edited or deleted — it records a taxable supply,
23 * and the numbering has to stay unbroken. So when something is wrong with one,
24 * the answer is not to fix it quietly but to issue a second document that says
25 * what changed: a credit note, referencing the original, with its own number in
26 * its own sequence. The original stands, the correction is recorded, and the net
27 * position is visible to anyone auditing either.
28 *
29 * Until now the plugin had no way to express any of that. The only ways to undo
30 * an invoice were to edit it — destroying the evidence that anything changed —
31 * or to delete it, which InvoiceRetention now refuses. This is the third option
32 * those two were missing.
33 *
34 * Storage
35 * -------
36 * A credit note is its own post type but carries invoice-shaped meta, so
37 * `Models\Invoice` hydrates it, the PDF renderer draws it, and the e-invoicing
38 * addon can map it without a parallel set of everything. It differs from an
39 * invoice in what it means, not in what it is made of.
40 *
41 * Amounts are stored positive. A credit note for 120.00 says "120.00 is
42 * credited", not "-120.00 is owed"; the sign lives in the document type, which
43 * is how EN 16931 models it too (type code 381).
44 */
45 class CreditNote {
46
47 /** Meta on the credit note: which invoice it credits. */
48 const META_CREDITED_INVOICE = '_easy_invoice_credited_invoice_id';
49
50 /** Meta on the credit note: why it was issued. */
51 const META_REASON = '_easy_invoice_credit_reason';
52
53 /** Option holding the next number in the credit-note series. */
54 const OPTION_NEXT_NUMBER = 'easy_invoice_next_credit_note_number';
55
56 /** Option holding the credit-note number prefix. */
57 const OPTION_PREFIX = 'easy_invoice_credit_note_prefix';
58
59 /** MySQL advisory lock guarding number generation. */
60 const NUMBER_LOCK = 'easy_invoice_credit_note_number_gen';
61
62 /**
63 * Issue a credit note against an invoice.
64 *
65 * @param int $invoice_id Invoice to credit.
66 * @param array $args {
67 * @type float|null $amount Amount to credit. Null credits the full
68 * outstanding amount and copies the invoice's
69 * line items.
70 * @type string $reason Why the credit is being issued.
71 * @type string $date Issue date (Y-m-d). Defaults to today.
72 * }
73 * @return int|\WP_Error New credit note ID.
74 */
75 public static function create( int $invoice_id, array $args = [] ) {
76 // The "how much is still creditable" check and the credit note that
77 // consumes it must be one step: three simultaneous 60 credits against
78 // a 100 invoice all passed the check and 180 was credited. One named
79 // lock per invoice serialises them; the numbering lock inside stays.
80 global $wpdb;
81 $lock_name = 'easy_invoice_credit_' . $invoice_id;
82 $locked = (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, 5 ) );
83 try {
84 return self::createLocked( $invoice_id, $args );
85 } finally {
86 if ( $locked ) {
87 $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
88 }
89 }
90 }
91
92 /**
93 * Body of create(); runs with the per-invoice lock held.
94 *
95 * @param int $invoice_id Invoice being credited.
96 * @param array $args See create().
97 * @return \WP_Post|int|\WP_Error
98 */
99 private static function createLocked( int $invoice_id, array $args = [] ) {
100 $invoice_post = get_post( $invoice_id );
101
102 if ( ! $invoice_post instanceof \WP_Post || PostTypes::EASY_INVOICE_POST_TYPE !== $invoice_post->post_type ) {
103 return new \WP_Error(
104 'easy_invoice_credit_no_invoice',
105 __( 'That invoice does not exist.', 'easy-invoice' )
106 );
107 }
108
109 // Crediting a draft is meaningless — nothing was ever claimed, so edit
110 // the draft instead. This also stops a credit note referencing a
111 // document that may still change under it.
112 if ( ! InvoiceRetention::isIssued( $invoice_id ) ) {
113 return new \WP_Error(
114 'easy_invoice_credit_draft',
115 __( 'This invoice is still a draft. Edit it directly — a credit note only makes sense once an invoice has been issued.', 'easy-invoice' )
116 );
117 }
118
119 $invoice = new \EasyInvoice\Models\Invoice( $invoice_post );
120 $total = round( (float) $invoice->getTotal(), 2 );
121
122 $remaining = self::remainingCreditable( $invoice_id );
123 if ( $remaining <= 0 ) {
124 return new \WP_Error(
125 'easy_invoice_credit_fully_credited',
126 __( 'This invoice has already been credited in full.', 'easy-invoice' )
127 );
128 }
129
130 $amount = isset( $args['amount'] ) && null !== $args['amount']
131 ? round( (float) $args['amount'], 2 )
132 : $remaining;
133
134 if ( $amount <= 0 ) {
135 return new \WP_Error(
136 'easy_invoice_credit_zero',
137 __( 'A credit note has to be for more than zero.', 'easy-invoice' )
138 );
139 }
140
141 // Over-crediting would show the customer owing you money on a document
142 // that exists to say the opposite.
143 if ( $amount > $remaining + 0.001 ) {
144 return new \WP_Error(
145 'easy_invoice_credit_too_large',
146 sprintf(
147 /* translators: 1: requested amount, 2: amount still creditable. */
148 __( 'You asked to credit %1$s, but only %2$s of this invoice is left to credit.', 'easy-invoice' ),
149 number_format_i18n( $amount, 2 ),
150 number_format_i18n( $remaining, 2 )
151 )
152 );
153 }
154
155 $is_full = abs( $amount - $total ) < 0.01 && empty( self::forInvoice( $invoice_id ) );
156 $number = self::nextNumber();
157 $date = ! empty( $args['date'] ) ? sanitize_text_field( $args['date'] ) : current_time( 'Y-m-d' );
158 $reason = isset( $args['reason'] ) ? sanitize_textarea_field( $args['reason'] ) : '';
159
160 $credit_id = wp_insert_post(
161 [
162 'post_type' => PostTypes::EASY_INVOICE_CREDIT_NOTE_POST_TYPE,
163 'post_status' => 'publish',
164 'post_title' => sprintf(
165 /* translators: 1: credit note number, 2: invoice number. */
166 __( '%1$s (credits %2$s)', 'easy-invoice' ),
167 $number,
168 (string) $invoice->getNumber()
169 ),
170 'post_author' => get_current_user_id() ?: $invoice_post->post_author,
171 ],
172 true
173 );
174
175 if ( is_wp_error( $credit_id ) ) {
176 return $credit_id;
177 }
178
179 $credit_id = (int) $credit_id;
180
181 // Carry the customer across verbatim. A credit note is addressed to the
182 // same party as the invoice it corrects, and the tax identifiers have to
183 // travel with it or the structured version fails its own validation.
184 self::copyMeta(
185 $invoice_id,
186 $credit_id,
187 [
188 '_easy_invoice_customer_name',
189 '_easy_invoice_customer_email',
190 '_easy_invoice_customer_address',
191 '_easy_invoice_client_id',
192 '_easy_invoice_currency_code',
193 '_easy_invoice_currency_position',
194 '_easy_invoice_tax_rate',
195 TaxTreatment::META_CUSTOMER_VAT,
196 TaxTreatment::META_CUSTOMER_COUNTRY,
197 TaxTreatment::META_TAX_CATEGORY,
198 ]
199 );
200
201 update_post_meta( $credit_id, '_easy_invoice_number', $number );
202 update_post_meta( $credit_id, '_easy_invoice_issue_date', $date );
203 update_post_meta( $credit_id, '_easy_invoice_status', 'available' );
204 update_post_meta( $credit_id, self::META_CREDITED_INVOICE, $invoice_id );
205 update_post_meta( $credit_id, self::META_REASON, $reason );
206
207 // A full credit reproduces the invoice line for line, so the customer
208 // can see exactly what is being reversed. A partial one cannot -- there
209 // is no honest way to guess which lines a part-refund relates to -- so
210 // it carries a single line naming the invoice.
211 $items = $is_full
212 ? (array) get_post_meta( $invoice_id, '_easy_invoice_items', true )
213 : [
214 [
215 'name' => sprintf(
216 /* translators: %s: invoice number. */
217 __( 'Credit against invoice %s', 'easy-invoice' ),
218 (string) $invoice->getNumber()
219 ),
220 'description' => $reason,
221 'quantity' => 1,
222 'price' => $amount,
223 'amount' => $amount,
224 'taxable' => false,
225 ],
226 ];
227
228 update_post_meta( $credit_id, '_easy_invoice_items', $items );
229
230 if ( $is_full ) {
231 // Reuse the invoice's own figures rather than recomputing them, so a
232 // full credit is exactly the inverse of what was billed -- including
233 // any rounding the invoice happened to land on.
234 self::copyMeta( $invoice_id, $credit_id, [ '_easy_invoice_subtotal', '_easy_invoice_discount_amount', '_easy_invoice_tax_amount' ] );
235 update_post_meta( $credit_id, '_easy_invoice_total', $total );
236 } else {
237 update_post_meta( $credit_id, '_easy_invoice_subtotal', $amount );
238 update_post_meta( $credit_id, '_easy_invoice_tax_amount', 0 );
239 update_post_meta( $credit_id, '_easy_invoice_discount_amount', 0 );
240 update_post_meta( $credit_id, '_easy_invoice_total', $amount );
241 }
242
243 // An unpaid invoice that has now been credited in full has nothing
244 // left to collect: it is cancelled. A paid invoice keeps its status --
245 // the money was received; the credit is the refund's paperwork.
246 $status = strtolower( (string) $invoice->getStatus() );
247 if ( ! in_array( $status, [ 'paid', 'partial', 'cancelled', 'canceled' ], true )
248 && self::remainingCreditable( $invoice_id ) <= 0.005 ) {
249 update_post_meta( $invoice_id, '_easy_invoice_status', 'cancelled' );
250 } elseif ( 'partial' === $status ) {
251 // Part paid, and the credit covers what was left: nothing more is
252 // owed, so the invoice is paid (the credit note explains the gap).
253 $fresh = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find( $invoice_id );
254 if ( $fresh && InvoiceBalance::isSettled( $fresh ) ) {
255 update_post_meta( $invoice_id, '_easy_invoice_status', 'paid' );
256 }
257 }
258
259 /**
260 * Fires once a credit note has been issued.
261 *
262 * @param int $credit_id New credit note ID.
263 * @param int $invoice_id Invoice it credits.
264 * @param float $amount Amount credited.
265 */
266 do_action( 'easy_invoice_credit_note_created', $credit_id, $invoice_id, $amount );
267
268 return $credit_id;
269 }
270
271 /**
272 * Credit notes issued against an invoice, newest first.
273 *
274 * @param int $invoice_id Invoice ID.
275 * @return int[]
276 */
277 public static function forInvoice( int $invoice_id ): array {
278 if ( $invoice_id <= 0 ) {
279 return [];
280 }
281
282 return get_posts(
283 [
284 'post_type' => PostTypes::EASY_INVOICE_CREDIT_NOTE_POST_TYPE,
285 'post_status' => [ 'publish', 'draft' ],
286 'numberposts' => -1,
287 'fields' => 'ids',
288 'no_found_rows' => true,
289 'suppress_filters' => true,
290 'orderby' => 'ID',
291 'order' => 'DESC',
292 'meta_query' => [
293 [
294 'key' => self::META_CREDITED_INVOICE,
295 'value' => $invoice_id,
296 ],
297 ],
298 ]
299 );
300 }
301
302 /**
303 * How much of an invoice has already been credited.
304 *
305 * @param int $invoice_id Invoice ID.
306 * @return float
307 */
308 public static function creditedTotal( int $invoice_id ): float {
309 $total = 0.0;
310
311 foreach ( self::forInvoice( $invoice_id ) as $credit_id ) {
312 $total += (float) get_post_meta( $credit_id, '_easy_invoice_total', true );
313 }
314
315 return round( $total, 2 );
316 }
317
318 /**
319 * How much of an invoice can still be credited.
320 *
321 * @param int $invoice_id Invoice ID.
322 * @return float
323 */
324 public static function remainingCreditable( int $invoice_id ): float {
325 wp_cache_delete( $invoice_id, 'post_meta' );
326 $post = get_post( $invoice_id );
327 if ( ! $post instanceof \WP_Post ) {
328 return 0.0;
329 }
330
331 $invoice = new \EasyInvoice\Models\Invoice( $post );
332
333 return round( (float) $invoice->getTotal() - self::creditedTotal( $invoice_id ), 2 );
334 }
335
336 /**
337 * The invoice a credit note was issued against.
338 *
339 * @param int $credit_id Credit note ID.
340 * @return int Zero when there is none.
341 */
342 public static function invoiceFor( int $credit_id ): int {
343 return (int) get_post_meta( $credit_id, self::META_CREDITED_INVOICE, true );
344 }
345
346 /**
347 * The next number in the credit-note series.
348 *
349 * A separate sequence from invoices, which is what tax authorities expect —
350 * CN-000001 alongside INV-000001, each unbroken in its own right. Generated
351 * under the same advisory lock the invoice numbers use, because two
352 * simultaneous credits would otherwise read the same counter and both claim
353 * the same number.
354 *
355 * @return string
356 */
357 public static function nextNumber(): string {
358 global $wpdb;
359
360 $prefix = (string) get_option( self::OPTION_PREFIX, 'CN-' );
361 $locked = false;
362
363 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- advisory lock, not a data query.
364 if ( $wpdb instanceof \wpdb ) {
365 $locked = (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', self::NUMBER_LOCK, 5 ) );
366 }
367
368 try {
369 // Re-read under the lock: the options cache was filled before this
370 // request waited for the lock, so a plain get_option() can hand
371 // two concurrent credit notes the same number.
372 wp_cache_delete( self::OPTION_NEXT_NUMBER, 'options' );
373 wp_cache_delete( 'alloptions', 'options' );
374 $next = max( 1, (int) get_option( self::OPTION_NEXT_NUMBER, 1 ) );
375
376 // Skip anything already taken — a restored backup or an import can
377 // leave the counter behind the numbers actually in use.
378 for ( $i = 0; $i < 1000; $i++ ) {
379 $candidate = $prefix . str_pad( (string) $next, 6, '0', STR_PAD_LEFT );
380 if ( ! self::numberExists( $candidate ) ) {
381 update_option( self::OPTION_NEXT_NUMBER, $next + 1 );
382 return $candidate;
383 }
384 $next++;
385 }
386
387 return $prefix . str_pad( (string) ( $next + time() ), 6, '0', STR_PAD_LEFT );
388 } finally {
389 if ( $locked && $wpdb instanceof \wpdb ) {
390 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- advisory lock.
391 $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', self::NUMBER_LOCK ) );
392 }
393 }
394 }
395
396 /**
397 * Is this credit note number already used?
398 *
399 * @param string $number Candidate number.
400 * @return bool
401 */
402 private static function numberExists( string $number ): bool {
403 $found = get_posts(
404 [
405 'post_type' => PostTypes::EASY_INVOICE_CREDIT_NOTE_POST_TYPE,
406 'post_status' => 'any',
407 'numberposts' => 1,
408 'fields' => 'ids',
409 'no_found_rows' => true,
410 'suppress_filters' => true,
411 'meta_query' => [
412 [
413 'key' => '_easy_invoice_number',
414 'value' => $number,
415 ],
416 ],
417 ]
418 );
419
420 return ! empty( $found );
421 }
422
423 /**
424 * Copy a set of meta keys from one post to another.
425 *
426 * @param int $from Source post.
427 * @param int $to Target post.
428 * @param string[] $keys Meta keys.
429 * @return void
430 */
431 private static function copyMeta( int $from, int $to, array $keys ): void {
432 foreach ( $keys as $key ) {
433 $value = get_post_meta( $from, $key, true );
434 if ( '' !== $value && null !== $value ) {
435 update_post_meta( $to, $key, $value );
436 }
437 }
438 }
439 }
440