# easy-invoice/2.4.2/includes/Services/TaxTreatment.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.2. 326 lines.

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

```php
<?php
/**
 * Decides which tax treatment an invoice attracts.
 *
 * @package Easy_Invoice
 * @subpackage Services
 */

namespace EasyInvoice\Services;

use EasyInvoice\Helpers\TaxCategory;

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

/**
 * Works out the tax category for a document from who is billing whom.
 *
 * Why this exists
 * ---------------
 * A UK or German agency invoicing a business in another EU member state must not
 * charge VAT — the customer accounts for it themselves, and the invoice has to
 * say so. Until now the plugin had no way to express that: it could only apply
 * its single rate or switch tax off entirely, and switching it off leaves no
 * statement on the document, which makes the invoice invalid rather than
 * zero-rated. This is a live problem for anyone trading cross-border, quite
 * separately from the e-invoicing mandates it also unblocks.
 *
 * Deliberately conservative
 * -------------------------
 * Everything here is off unless the merchant enables it and supplies the
 * information it needs. If the feature is disabled, or either party's country or
 * VAT number is unknown, the answer is always the standard rate — exactly what
 * the plugin did before. An upgrade therefore cannot silently change the tax on
 * anyone's invoices, which matters because these are legal documents.
 *
 * This is a determination, not tax advice: it encodes the ordinary intra-EU B2B
 * rule and gives the merchant an override.
 */
class TaxTreatment {

    /** Option: is automatic reverse-charge determination switched on? */
    const OPTION_ENABLED = 'easy_invoice_reverse_charge_enabled';

    /** Option: the supplier's own VAT identifier. */
    const OPTION_SUPPLIER_VAT = 'easy_invoice_company_vat_number';

    /** Option: the supplier's country, as an ISO 3166-1 alpha-2 code. */
    const OPTION_SUPPLIER_COUNTRY = 'easy_invoice_company_country';

    /** Per-document meta: the customer's VAT identifier. */
    const META_CUSTOMER_VAT = '_easy_invoice_customer_vat_number';

    /** Per-document meta: the customer's country. */
    const META_CUSTOMER_COUNTRY = '_easy_invoice_customer_country';

    /** Per-document meta: an explicit category chosen by the merchant. */
    const META_TAX_CATEGORY = '_easy_invoice_tax_category';

    /**
     * EU member states, by ISO 3166-1 alpha-2.
     *
     * Northern Ireland trades under XI for VAT purposes on goods; GB is outside
     * the EU VAT area since 2021 and is treated as an export.
     *
     * @return string[]
     */
    public static function euMemberStates(): array {
        return [
            'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
            'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
            'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'XI',
        ];
    }

    /**
     * Is a country inside the EU VAT area?
     *
     * @param string $country ISO 3166-1 alpha-2 code.
     * @return bool
     */
    public static function isEu( string $country ): bool {
        return in_array( strtoupper( trim( $country ) ), self::euMemberStates(), true );
    }

    /**
     * Has the merchant switched automatic determination on?
     *
     * @return bool
     */
    public static function isEnabled(): bool {
        return 'yes' === get_option( self::OPTION_ENABLED, 'no' );
    }

    /**
     * Determine the tax treatment for a document.
     *
     * @param object $document Invoice or Quote model.
     * @return array{category:string,reason:string,charges_tax:bool,automatic:bool}
     */
    public static function forDocument( $document ): array {
        $standard = [
            'category'    => TaxCategory::STANDARD,
            'reason'      => '',
            'charges_tax' => true,
            'automatic'   => false,
        ];

        $id = is_callable( [ $document, 'getId' ] ) ? (int) $document->getId() : 0;
        if ( $id <= 0 ) {
            return $standard;
        }

        // An explicit choice by the merchant always wins. Automatic determination
        // is a convenience, never a constraint — an accountant who knows the
        // correct treatment must be able to state it.
        $explicit = (string) get_post_meta( $id, self::META_TAX_CATEGORY, true );
        if ( '' !== $explicit && TaxCategory::isValid( $explicit ) ) {
            return [
                'category'    => $explicit,
                'reason'      => TaxCategory::defaultReason( $explicit ),
                'charges_tax' => TaxCategory::isTaxable( $explicit ),
                'automatic'   => false,
            ];
        }

        if ( ! self::isEnabled() ) {
            return $standard;
        }

        $supplier_country = strtoupper( trim( (string) get_option( self::OPTION_SUPPLIER_COUNTRY, '' ) ) );
        $supplier_vat     = trim( (string) get_option( self::OPTION_SUPPLIER_VAT, '' ) );
        $customer_country = strtoupper( trim( (string) get_post_meta( $id, self::META_CUSTOMER_COUNTRY, true ) ) );
        $customer_vat     = trim( (string) get_post_meta( $id, self::META_CUSTOMER_VAT, true ) );

        // Missing information means we do not know, and "do not know" must resolve
        // to the ordinary rate rather than to a guess that removes tax.
        if ( '' === $supplier_country || '' === $customer_country || '' === $supplier_vat || '' === $customer_vat ) {
            return $standard;
        }

        // Same country: an ordinary domestic supply, tax as usual.
        if ( $supplier_country === $customer_country ) {
            return $standard;
        }

        $result = $standard;

        if ( self::isEu( $supplier_country ) && self::isEu( $customer_country ) ) {
            // Cross-border B2B inside the EU, both parties VAT-registered: the
            // customer accounts for the tax.
            $result = [
                'category'    => TaxCategory::REVERSE_CHARGE,
                'reason'      => TaxCategory::defaultReason( TaxCategory::REVERSE_CHARGE ),
                'charges_tax' => false,
                'automatic'   => true,
            ];
        } elseif ( self::isEu( $supplier_country ) && ! self::isEu( $customer_country ) ) {
            // Supply leaving the EU.
            $result = [
                'category'    => TaxCategory::EXPORT,
                'reason'      => TaxCategory::defaultReason( TaxCategory::EXPORT ),
                'charges_tax' => false,
                'automatic'   => true,
            ];
        }

        /**
         * Filter the determined tax treatment.
         *
         * Local rules vary and this deliberately encodes only the common case, so
         * an integrator or accountant can correct it.
         *
         * @param array  $result   Determined treatment.
         * @param object $document Invoice or Quote model.
         */
        return (array) apply_filters( 'easy_invoice_tax_treatment', $result, $document );
    }

    /**
     * Should tax be charged on this document at all?
     *
     * @param object $document Invoice or Quote model.
     * @return bool
     */
    public static function chargesTax( $document ): bool {
        $treatment = self::forDocument( $document );
        return ! empty( $treatment['charges_tax'] );
    }

    /**
     * The statement that must appear on the document, if any.
     *
     * @param object $document Invoice or Quote model.
     * @return string
     */
    public static function statementFor( $document ): string {
        $treatment = self::forDocument( $document );
        return TaxCategory::requiresReason( $treatment['category'] ) ? (string) $treatment['reason'] : '';
    }

    /**
     * Add the reverse-charge switch to the tax settings panel.
     *
     * @param array $fields Fields contributed by other code.
     * @return array
     */
    public static function registerSetting( $fields ): array {
        $fields = is_array( $fields ) ? $fields : [];

        $fields[ self::OPTION_ENABLED ] = [
            'label'       => __( 'Work out reverse charge and exports automatically', 'easy-invoice' ),
            'type'        => 'checkbox',
            'default'     => 'no',
            'col_span'    => 'sm:col-span-6',
            'description' => __( 'When you and your customer are VAT-registered in different EU countries, no VAT is charged and the invoice states that the customer accounts for it. Supplies outside the EU are zero-rated as exports. Needs your VAT number and country under Company Information, and the customer\'s on the invoice.', 'easy-invoice' ),
        ];

        return $fields;
    }

    /**
     * Print the tax statement wherever a document renders its totals.
     *
     * Hooked rather than written into a template: the plugin ships ten invoice
     * designs and six quote designs, plus the PDF layout, and a legally-required
     * statement cannot be present in some of them and missing from others. Every
     * design already fires `easy_invoice_invoice_totals_after_tax`, so one
     * listener covers all of them, including any custom template that follows the
     * same convention.
     *
     * @param object $document Invoice or Quote model.
     * @return void
     */
    public static function renderStatement( $document ): void {
        if ( ! is_object( $document ) ) {
            return;
        }

        $statement = self::statementFor( $document );
        if ( '' === $statement ) {
            return;
        }

        printf(
            '<div class="ei-tax-statement" style="margin-top:10px;padding:9px 11px;background:#f6f7f9;border-left:3px solid #6b7280;font-size:0.875rem;color:#374151;">%s</div>',
            esc_html( $statement )
        );
    }

    /**
     * Register the statement against the hooks documents fire when rendering totals.
     *
     * @return void
     */
    public static function init(): void {
        // The tax settings panel renders a fixed list of keys and exposes
        // `easy_invoice_tax_section_additional_fields` for anything else — the same
        // extension point the Additional Tax addon uses. Adding the option to the
        // settings config array alone does nothing, because that panel never reads it.
        add_filter( 'easy_invoice_tax_section_additional_fields', [ __CLASS__, 'registerSetting' ] );

        add_action( 'easy_invoice_invoice_totals_after_tax', [ __CLASS__, 'renderStatement' ] );
        add_action( 'easy_invoice_quote_totals_after_tax', [ __CLASS__, 'renderStatement' ] );
        add_action( 'easy_invoice_pdf_totals_after_tax', [ __CLASS__, 'renderStatementRow' ] );
    }

    /**
     * The same statement, as a table row, for the PDF totals block.
     *
     * @param object $document Invoice or Quote model.
     * @return void
     */
    public static function renderStatementRow( $document ): void {
        if ( ! is_object( $document ) ) {
            return;
        }

        $statement = self::statementFor( $document );
        if ( '' === $statement ) {
            return;
        }

        printf(
            '<tr><td colspan="2" style="font-size:8.5pt;color:#555555;padding-top:6pt">%s</td></tr>',
            esc_html( $statement )
        );
    }

    /**
     * Is a VAT identifier structurally plausible for its country?
     *
     * A syntax check only — it says nothing about whether the number is
     * registered, which requires VIES. It exists to catch the common case of a
     * number typed with the wrong country prefix or an obviously wrong length
     * before the invoice is sent.
     *
     * @param string $vat     VAT identifier, with or without country prefix.
     * @param string $country Expected ISO 3166-1 alpha-2 code.
     * @return bool
     */
    public static function looksLikeValidVat( string $vat, string $country = '' ): bool {
        $vat = strtoupper( preg_replace( '/[\s.\-]/', '', $vat ) );
        if ( '' === $vat ) {
            return false;
        }

        // Most EU numbers are the country code followed by 2-13 alphanumerics.
        if ( ! preg_match( '/^([A-Z]{2})([A-Z0-9]{2,13})$/', $vat, $m ) ) {
            return false;
        }

        if ( '' !== $country && strtoupper( $country ) !== $m[1] ) {
            // Greece files VAT under EL while its ISO code is GR — the one
            // routine mismatch that is not an error.
            $greece = ( 'GR' === strtoupper( $country ) && 'EL' === $m[1] );
            if ( ! $greece ) {
                return false;
            }
        }

        return true;
    }
}

```
