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( '
%s
', 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( '%s', 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; } }