# easy-invoice/2.4.1/includes/Services/CreditNote.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.1. 440 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.1/code/includes/Services/CreditNote.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.1/raw/includes/Services/CreditNote.php
- Modified: 2026-09-15T12:31:20+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/easy-invoice/2.4.1/code/includes/Services/CreditNote.php#L10-L20`.

```php
<?php
/**
 * Credit notes — the lawful way to undo an invoice.
 *
 * @package Easy_Invoice
 * @subpackage Services
 */

namespace EasyInvoice\Services;

use EasyInvoice\Constants\PostTypes;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Issues credit notes against invoices.
 *
 * Why this exists
 * ---------------
 * An issued invoice cannot be edited or deleted — it records a taxable supply,
 * and the numbering has to stay unbroken. So when something is wrong with one,
 * the answer is not to fix it quietly but to issue a second document that says
 * what changed: a credit note, referencing the original, with its own number in
 * its own sequence. The original stands, the correction is recorded, and the net
 * position is visible to anyone auditing either.
 *
 * Until now the plugin had no way to express any of that. The only ways to undo
 * an invoice were to edit it — destroying the evidence that anything changed —
 * or to delete it, which InvoiceRetention now refuses. This is the third option
 * those two were missing.
 *
 * Storage
 * -------
 * A credit note is its own post type but carries invoice-shaped meta, so
 * `Models\Invoice` hydrates it, the PDF renderer draws it, and the e-invoicing
 * addon can map it without a parallel set of everything. It differs from an
 * invoice in what it means, not in what it is made of.
 *
 * Amounts are stored positive. A credit note for 120.00 says "120.00 is
 * credited", not "-120.00 is owed"; the sign lives in the document type, which
 * is how EN 16931 models it too (type code 381).
 */
class CreditNote {

    /** Meta on the credit note: which invoice it credits. */
    const META_CREDITED_INVOICE = '_easy_invoice_credited_invoice_id';

    /** Meta on the credit note: why it was issued. */
    const META_REASON = '_easy_invoice_credit_reason';

    /** Option holding the next number in the credit-note series. */
    const OPTION_NEXT_NUMBER = 'easy_invoice_next_credit_note_number';

    /** Option holding the credit-note number prefix. */
    const OPTION_PREFIX = 'easy_invoice_credit_note_prefix';

    /** MySQL advisory lock guarding number generation. */
    const NUMBER_LOCK = 'easy_invoice_credit_note_number_gen';

    /**
     * Issue a credit note against an invoice.
     *
     * @param int   $invoice_id Invoice to credit.
     * @param array $args       {
     *     @type float|null $amount Amount to credit. Null credits the full
     *                              outstanding amount and copies the invoice's
     *                              line items.
     *     @type string     $reason Why the credit is being issued.
     *     @type string     $date   Issue date (Y-m-d). Defaults to today.
     * }
     * @return int|\WP_Error New credit note ID.
     */
    public static function create( int $invoice_id, array $args = [] ) {
        // The "how much is still creditable" check and the credit note that
        // consumes it must be one step: three simultaneous 60 credits against
        // a 100 invoice all passed the check and 180 was credited. One named
        // lock per invoice serialises them; the numbering lock inside stays.
        global $wpdb;
        $lock_name = 'easy_invoice_credit_' . $invoice_id;
        $locked    = (bool) $wpdb->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 );
            }
        }
    }
}

```
