get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, 5 ) ); try { return self::createLocked( $invoice_id, $args ); } finally { if ( $locked ) { $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) ); } } } /** * Body of create(); runs with the per-invoice lock held. * * @param int $invoice_id Invoice being credited. * @param array $args See create(). * @return \WP_Post|int|\WP_Error */ private static function createLocked( int $invoice_id, array $args = [] ) { $invoice_post = get_post( $invoice_id ); if ( ! $invoice_post instanceof \WP_Post || PostTypes::EASY_INVOICE_POST_TYPE !== $invoice_post->post_type ) { return new \WP_Error( 'easy_invoice_credit_no_invoice', __( 'That invoice does not exist.', 'easy-invoice' ) ); } // Crediting a draft is meaningless — nothing was ever claimed, so edit // the draft instead. This also stops a credit note referencing a // document that may still change under it. if ( ! InvoiceRetention::isIssued( $invoice_id ) ) { return new \WP_Error( 'easy_invoice_credit_draft', __( 'This invoice is still a draft. Edit it directly — a credit note only makes sense once an invoice has been issued.', 'easy-invoice' ) ); } $invoice = new \EasyInvoice\Models\Invoice( $invoice_post ); $total = round( (float) $invoice->getTotal(), 2 ); $remaining = self::remainingCreditable( $invoice_id ); if ( $remaining <= 0 ) { return new \WP_Error( 'easy_invoice_credit_fully_credited', __( 'This invoice has already been credited in full.', 'easy-invoice' ) ); } $amount = isset( $args['amount'] ) && null !== $args['amount'] ? round( (float) $args['amount'], 2 ) : $remaining; if ( $amount <= 0 ) { return new \WP_Error( 'easy_invoice_credit_zero', __( 'A credit note has to be for more than zero.', 'easy-invoice' ) ); } // Over-crediting would show the customer owing you money on a document // that exists to say the opposite. if ( $amount > $remaining + 0.001 ) { return new \WP_Error( 'easy_invoice_credit_too_large', sprintf( /* translators: 1: requested amount, 2: amount still creditable. */ __( 'You asked to credit %1$s, but only %2$s of this invoice is left to credit.', 'easy-invoice' ), number_format_i18n( $amount, 2 ), number_format_i18n( $remaining, 2 ) ) ); } $is_full = abs( $amount - $total ) < 0.01 && empty( self::forInvoice( $invoice_id ) ); $number = self::nextNumber(); $date = ! empty( $args['date'] ) ? sanitize_text_field( $args['date'] ) : current_time( 'Y-m-d' ); $reason = isset( $args['reason'] ) ? sanitize_textarea_field( $args['reason'] ) : ''; $credit_id = wp_insert_post( [ 'post_type' => PostTypes::EASY_INVOICE_CREDIT_NOTE_POST_TYPE, 'post_status' => 'publish', 'post_title' => sprintf( /* translators: 1: credit note number, 2: invoice number. */ __( '%1$s (credits %2$s)', 'easy-invoice' ), $number, (string) $invoice->getNumber() ), 'post_author' => get_current_user_id() ?: $invoice_post->post_author, ], true ); if ( is_wp_error( $credit_id ) ) { return $credit_id; } $credit_id = (int) $credit_id; // Carry the customer across verbatim. A credit note is addressed to the // same party as the invoice it corrects, and the tax identifiers have to // travel with it or the structured version fails its own validation. self::copyMeta( $invoice_id, $credit_id, [ '_easy_invoice_customer_name', '_easy_invoice_customer_email', '_easy_invoice_customer_address', '_easy_invoice_client_id', '_easy_invoice_currency_code', '_easy_invoice_currency_position', '_easy_invoice_tax_rate', TaxTreatment::META_CUSTOMER_VAT, TaxTreatment::META_CUSTOMER_COUNTRY, TaxTreatment::META_TAX_CATEGORY, ] ); update_post_meta( $credit_id, '_easy_invoice_number', $number ); update_post_meta( $credit_id, '_easy_invoice_issue_date', $date ); update_post_meta( $credit_id, '_easy_invoice_status', 'available' ); update_post_meta( $credit_id, self::META_CREDITED_INVOICE, $invoice_id ); update_post_meta( $credit_id, self::META_REASON, $reason ); // A full credit reproduces the invoice line for line, so the customer // can see exactly what is being reversed. A partial one cannot -- there // is no honest way to guess which lines a part-refund relates to -- so // it carries a single line naming the invoice. $items = $is_full ? (array) get_post_meta( $invoice_id, '_easy_invoice_items', true ) : [ [ 'name' => sprintf( /* translators: %s: invoice number. */ __( 'Credit against invoice %s', 'easy-invoice' ), (string) $invoice->getNumber() ), 'description' => $reason, 'quantity' => 1, 'price' => $amount, 'amount' => $amount, 'taxable' => false, ], ]; update_post_meta( $credit_id, '_easy_invoice_items', $items ); if ( $is_full ) { // Reuse the invoice's own figures rather than recomputing them, so a // full credit is exactly the inverse of what was billed -- including // any rounding the invoice happened to land on. self::copyMeta( $invoice_id, $credit_id, [ '_easy_invoice_subtotal', '_easy_invoice_discount_amount', '_easy_invoice_tax_amount' ] ); update_post_meta( $credit_id, '_easy_invoice_total', $total ); } else { update_post_meta( $credit_id, '_easy_invoice_subtotal', $amount ); update_post_meta( $credit_id, '_easy_invoice_tax_amount', 0 ); update_post_meta( $credit_id, '_easy_invoice_discount_amount', 0 ); update_post_meta( $credit_id, '_easy_invoice_total', $amount ); } // An unpaid invoice that has now been credited in full has nothing // left to collect: it is cancelled. A paid invoice keeps its status -- // the money was received; the credit is the refund's paperwork. $status = strtolower( (string) $invoice->getStatus() ); if ( ! in_array( $status, [ 'paid', 'partial', 'cancelled', 'canceled' ], true ) && self::remainingCreditable( $invoice_id ) <= 0.005 ) { update_post_meta( $invoice_id, '_easy_invoice_status', 'cancelled' ); } elseif ( 'partial' === $status ) { // Part paid, and the credit covers what was left: nothing more is // owed, so the invoice is paid (the credit note explains the gap). $fresh = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find( $invoice_id ); if ( $fresh && InvoiceBalance::isSettled( $fresh ) ) { update_post_meta( $invoice_id, '_easy_invoice_status', 'paid' ); } } /** * Fires once a credit note has been issued. * * @param int $credit_id New credit note ID. * @param int $invoice_id Invoice it credits. * @param float $amount Amount credited. */ do_action( 'easy_invoice_credit_note_created', $credit_id, $invoice_id, $amount ); return $credit_id; } /** * Credit notes issued against an invoice, newest first. * * @param int $invoice_id Invoice ID. * @return int[] */ public static function forInvoice( int $invoice_id ): array { if ( $invoice_id <= 0 ) { return []; } return get_posts( [ 'post_type' => PostTypes::EASY_INVOICE_CREDIT_NOTE_POST_TYPE, 'post_status' => [ 'publish', 'draft' ], 'numberposts' => -1, 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => true, 'orderby' => 'ID', 'order' => 'DESC', 'meta_query' => [ [ 'key' => self::META_CREDITED_INVOICE, 'value' => $invoice_id, ], ], ] ); } /** * How much of an invoice has already been credited. * * @param int $invoice_id Invoice ID. * @return float */ public static function creditedTotal( int $invoice_id ): float { $total = 0.0; foreach ( self::forInvoice( $invoice_id ) as $credit_id ) { $total += (float) get_post_meta( $credit_id, '_easy_invoice_total', true ); } return round( $total, 2 ); } /** * How much of an invoice can still be credited. * * @param int $invoice_id Invoice ID. * @return float */ public static function remainingCreditable( int $invoice_id ): float { wp_cache_delete( $invoice_id, 'post_meta' ); $post = get_post( $invoice_id ); if ( ! $post instanceof \WP_Post ) { return 0.0; } $invoice = new \EasyInvoice\Models\Invoice( $post ); return round( (float) $invoice->getTotal() - self::creditedTotal( $invoice_id ), 2 ); } /** * The invoice a credit note was issued against. * * @param int $credit_id Credit note ID. * @return int Zero when there is none. */ public static function invoiceFor( int $credit_id ): int { return (int) get_post_meta( $credit_id, self::META_CREDITED_INVOICE, true ); } /** * The next number in the credit-note series. * * A separate sequence from invoices, which is what tax authorities expect — * CN-000001 alongside INV-000001, each unbroken in its own right. Generated * under the same advisory lock the invoice numbers use, because two * simultaneous credits would otherwise read the same counter and both claim * the same number. * * @return string */ public static function nextNumber(): string { global $wpdb; $prefix = (string) get_option( self::OPTION_PREFIX, 'CN-' ); $locked = false; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- advisory lock, not a data query. if ( $wpdb instanceof \wpdb ) { $locked = (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', self::NUMBER_LOCK, 5 ) ); } try { // Re-read under the lock: the options cache was filled before this // request waited for the lock, so a plain get_option() can hand // two concurrent credit notes the same number. wp_cache_delete( self::OPTION_NEXT_NUMBER, 'options' ); wp_cache_delete( 'alloptions', 'options' ); $next = max( 1, (int) get_option( self::OPTION_NEXT_NUMBER, 1 ) ); // Skip anything already taken — a restored backup or an import can // leave the counter behind the numbers actually in use. for ( $i = 0; $i < 1000; $i++ ) { $candidate = $prefix . str_pad( (string) $next, 6, '0', STR_PAD_LEFT ); if ( ! self::numberExists( $candidate ) ) { update_option( self::OPTION_NEXT_NUMBER, $next + 1 ); return $candidate; } $next++; } return $prefix . str_pad( (string) ( $next + time() ), 6, '0', STR_PAD_LEFT ); } finally { if ( $locked && $wpdb instanceof \wpdb ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- advisory lock. $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', self::NUMBER_LOCK ) ); } } } /** * Is this credit note number already used? * * @param string $number Candidate number. * @return bool */ private static function numberExists( string $number ): bool { $found = get_posts( [ 'post_type' => PostTypes::EASY_INVOICE_CREDIT_NOTE_POST_TYPE, 'post_status' => 'any', 'numberposts' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => true, 'meta_query' => [ [ 'key' => '_easy_invoice_number', 'value' => $number, ], ], ] ); return ! empty( $found ); } /** * Copy a set of meta keys from one post to another. * * @param int $from Source post. * @param int $to Target post. * @param string[] $keys Meta keys. * @return void */ private static function copyMeta( int $from, int $to, array $keys ): void { foreach ( $keys as $key ) { $value = get_post_meta( $from, $key, true ); if ( '' !== $value && null !== $value ) { update_post_meta( $to, $key, $value ); } } } }