# easy-invoice/2.4.0/includes/Services/InvoiceRetention.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.0. 170 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.0/code/includes/Services/InvoiceRetention.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.0/raw/includes/Services/InvoiceRetention.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.0/code/includes/Services/InvoiceRetention.php#L10-L20`.

```php
<?php
/**
 * Stops issued invoices being destroyed.
 *
 * @package Easy_Invoice
 * @subpackage Services
 */

namespace EasyInvoice\Services;

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

/**
 * Refuses permanent deletion of an invoice that has been issued.
 *
 * Why
 * ---
 * An issued invoice is not a record of your intentions, it is a record of a
 * taxable supply, and every tax authority this plugin's users answer to — HMRC,
 * the EU under the VAT Directive, the ATO, the IRS — requires it to be kept and
 * to carry an unbroken sequential identifier. Deleting one destroys evidence and
 * leaves a gap in the numbering that is precisely what an auditor looks for.
 *
 * The correct way to undo an invoice is to issue a credit note against it, which
 * leaves the original standing and records the correction. The correct way to
 * abandon one before it is sent is to delete the draft, which this still allows.
 *
 * What is still permitted
 * -----------------------
 * Everything except destroying the record:
 *
 *   ▸ Deleting a draft — it was never issued, so there is nothing to preserve
 *   ▸ Trashing an issued invoice — reversible, and the existing flow marks it
 *     cancelled on the way, so the document survives and says what happened
 *   ▸ Emptying the trash of drafts
 *
 * Only "delete permanently" on an issued invoice is refused, and only for
 * invoices. A quote is an offer, not a record of a supply, and is left alone.
 *
 * Two layers on purpose
 * ---------------------
 * `mayDelete()` lets a caller ask first and tell the user something useful.
 * The `pre_delete_post` filter is the backstop, because deletion can be reached
 * from the invoice screen, a bulk action, the REST route, the client cascade,
 * wp-admin's own post list and WP-CLI — and guarding each of those individually
 * is a list someone will add to without noticing.
 *
 * This does not fire during GDPR erasure: the eraser trashes rather than
 * deletes, deliberately, because the right to erasure does not override a
 * statutory retention period.
 */
class InvoiceRetention {

    /** Post types this protects. */
    const PROTECTED_TYPES = [ 'easy_invoice' ];

    /**
     * Register the backstop.
     *
     * @return void
     */
    public static function init(): void {
        add_filter( 'pre_delete_post', [ __CLASS__, 'blockDeletion' ], 10, 3 );
    }

    /**
     * Has this invoice been issued?
     *
     * Anything that is not a draft has, as far as the customer is concerned,
     * left the building — it has a number, and it may have been sent, paid or
     * reported. "Draft" is the one state where nothing is owed to anyone.
     *
     * @param int $post_id Invoice ID.
     * @return bool
     */
    public static function isIssued( int $post_id ): bool {
        $post = get_post( $post_id );
        if ( ! $post instanceof \WP_Post ) {
            return false;
        }

        if ( in_array( $post->post_status, [ 'draft', 'auto-draft' ], true ) ) {
            return false;
        }

        $status = (string) get_post_meta( $post_id, '_easy_invoice_status', true );

        return 'draft' !== $status;
    }

    /**
     * May this invoice be permanently deleted?
     *
     * @param int $post_id Invoice ID.
     * @return true|\WP_Error True, or an error explaining what to do instead.
     */
    public static function mayDelete( int $post_id ) {
        $post = get_post( $post_id );
        if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, self::PROTECTED_TYPES, true ) ) {
            return true;
        }

        if ( ! self::isIssued( $post_id ) ) {
            return true;
        }

        /**
         * Filter whether an issued invoice may be permanently deleted.
         *
         * The escape hatch for a site that has a genuine reason — a botched
         * import, a staging copy, a legal instruction. It is deliberately not a
         * setting: this should be a considered act by someone who can edit code,
         * not a checkbox someone ticks to make a warning go away.
         *
         * @param bool $allowed Whether deletion is allowed.
         * @param int  $post_id Invoice ID.
         */
        if ( apply_filters( 'easy_invoice_allow_issued_invoice_deletion', false, $post_id ) ) {
            return true;
        }

        $number = (string) get_post_meta( $post_id, '_easy_invoice_number', true );

        return new \WP_Error(
            'easy_invoice_issued_invoice_protected',
            $number
                ? sprintf(
                    /* translators: %s: invoice number. */
                    __( 'Invoice %s has been issued, so it cannot be deleted permanently — it is a tax record, and removing it breaks your numbering sequence. Move it to trash to take it out of your lists, or issue a credit note to cancel it out.', 'easy-invoice' ),
                    $number
                )
                : __( 'This invoice has been issued, so it cannot be deleted permanently — it is a tax record, and removing it breaks your numbering sequence. Move it to trash to take it out of your lists, or issue a credit note to cancel it out.', 'easy-invoice' ),
            [ 'status' => 409 ]
        );
    }

    /**
     * Short-circuit `wp_delete_post()` for a protected invoice.
     *
     * @param \WP_Post|false|null $check    Short-circuit value.
     * @param \WP_Post            $post     Post being deleted.
     * @param bool                $force    Whether this bypasses the trash.
     * @return \WP_Post|false|null
     */
    public static function blockDeletion( $check, $post, $force ) {
        // Without $force this is a move to trash, which is reversible and stays
        // allowed. WordPress also passes $force = true for post types that have
        // no trash support, so the type check below still matters.
        if ( ! $force ) {
            return $check;
        }

        if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, self::PROTECTED_TYPES, true ) ) {
            return $check;
        }

        $may = self::mayDelete( (int) $post->ID );
        if ( is_wp_error( $may ) ) {
            // false tells WordPress the deletion failed, which is exactly what
            // happened. Callers that used mayDelete() first will already have
            // told the user why; the rest at least do not silently succeed.
            return false;
        }

        return $check;
    }
}

```
