| 1 |
<?php |
| 2 |
/** |
| 3 |
* Decides which tax treatment an invoice attracts. |
| 4 |
* |
| 5 |
* @package Easy_Invoice |
| 6 |
* @subpackage Services |
| 7 |
*/ |
| 8 |
|
| 9 |
namespace EasyInvoice\Services; |
| 10 |
|
| 11 |
use EasyInvoice\Helpers\TaxCategory; |
| 12 |
|
| 13 |
if ( ! defined( 'ABSPATH' ) ) { |
| 14 |
exit; |
| 15 |
} |
| 16 |
|
| 17 |
/** |
| 18 |
* Works out the tax category for a document from who is billing whom. |
| 19 |
* |
| 20 |
* Why this exists |
| 21 |
* --------------- |
| 22 |
* A UK or German agency invoicing a business in another EU member state must not |
| 23 |
* charge VAT — the customer accounts for it themselves, and the invoice has to |
| 24 |
* say so. Until now the plugin had no way to express that: it could only apply |
| 25 |
* its single rate or switch tax off entirely, and switching it off leaves no |
| 26 |
* statement on the document, which makes the invoice invalid rather than |
| 27 |
* zero-rated. This is a live problem for anyone trading cross-border, quite |
| 28 |
* separately from the e-invoicing mandates it also unblocks. |
| 29 |
* |
| 30 |
* Deliberately conservative |
| 31 |
* ------------------------- |
| 32 |
* Everything here is off unless the merchant enables it and supplies the |
| 33 |
* information it needs. If the feature is disabled, or either party's country or |
| 34 |
* VAT number is unknown, the answer is always the standard rate — exactly what |
| 35 |
* the plugin did before. An upgrade therefore cannot silently change the tax on |
| 36 |
* anyone's invoices, which matters because these are legal documents. |
| 37 |
* |
| 38 |
* This is a determination, not tax advice: it encodes the ordinary intra-EU B2B |
| 39 |
* rule and gives the merchant an override. |
| 40 |
*/ |
| 41 |
class TaxTreatment { |
| 42 |
|
| 43 |
/** Option: is automatic reverse-charge determination switched on? */ |
| 44 |
const OPTION_ENABLED = 'easy_invoice_reverse_charge_enabled'; |
| 45 |
|
| 46 |
/** Option: the supplier's own VAT identifier. */ |
| 47 |
const OPTION_SUPPLIER_VAT = 'easy_invoice_company_vat_number'; |
| 48 |
|
| 49 |
/** Option: the supplier's country, as an ISO 3166-1 alpha-2 code. */ |
| 50 |
const OPTION_SUPPLIER_COUNTRY = 'easy_invoice_company_country'; |
| 51 |
|
| 52 |
/** Per-document meta: the customer's VAT identifier. */ |
| 53 |
const META_CUSTOMER_VAT = '_easy_invoice_customer_vat_number'; |
| 54 |
|
| 55 |
/** Per-document meta: the customer's country. */ |
| 56 |
const META_CUSTOMER_COUNTRY = '_easy_invoice_customer_country'; |
| 57 |
|
| 58 |
/** Per-document meta: an explicit category chosen by the merchant. */ |
| 59 |
const META_TAX_CATEGORY = '_easy_invoice_tax_category'; |
| 60 |
|
| 61 |
/** |
| 62 |
* EU member states, by ISO 3166-1 alpha-2. |
| 63 |
* |
| 64 |
* Northern Ireland trades under XI for VAT purposes on goods; GB is outside |
| 65 |
* the EU VAT area since 2021 and is treated as an export. |
| 66 |
* |
| 67 |
* @return string[] |
| 68 |
*/ |
| 69 |
public static function euMemberStates(): array { |
| 70 |
return [ |
| 71 |
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', |
| 72 |
'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', |
| 73 |
'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'XI', |
| 74 |
]; |
| 75 |
} |
| 76 |
|
| 77 |
/** |
| 78 |
* Is a country inside the EU VAT area? |
| 79 |
* |
| 80 |
* @param string $country ISO 3166-1 alpha-2 code. |
| 81 |
* @return bool |
| 82 |
*/ |
| 83 |
public static function isEu( string $country ): bool { |
| 84 |
return in_array( strtoupper( trim( $country ) ), self::euMemberStates(), true ); |
| 85 |
} |
| 86 |
|
| 87 |
/** |
| 88 |
* Has the merchant switched automatic determination on? |
| 89 |
* |
| 90 |
* @return bool |
| 91 |
*/ |
| 92 |
public static function isEnabled(): bool { |
| 93 |
return 'yes' === get_option( self::OPTION_ENABLED, 'no' ); |
| 94 |
} |
| 95 |
|
| 96 |
/** |
| 97 |
* Determine the tax treatment for a document. |
| 98 |
* |
| 99 |
* @param object $document Invoice or Quote model. |
| 100 |
* @return array{category:string,reason:string,charges_tax:bool,automatic:bool} |
| 101 |
*/ |
| 102 |
public static function forDocument( $document ): array { |
| 103 |
$standard = [ |
| 104 |
'category' => TaxCategory::STANDARD, |
| 105 |
'reason' => '', |
| 106 |
'charges_tax' => true, |
| 107 |
'automatic' => false, |
| 108 |
]; |
| 109 |
|
| 110 |
$id = is_callable( [ $document, 'getId' ] ) ? (int) $document->getId() : 0; |
| 111 |
if ( $id <= 0 ) { |
| 112 |
return $standard; |
| 113 |
} |
| 114 |
|
| 115 |
// An explicit choice by the merchant always wins. Automatic determination |
| 116 |
// is a convenience, never a constraint — an accountant who knows the |
| 117 |
// correct treatment must be able to state it. |
| 118 |
$explicit = (string) get_post_meta( $id, self::META_TAX_CATEGORY, true ); |
| 119 |
if ( '' !== $explicit && TaxCategory::isValid( $explicit ) ) { |
| 120 |
return [ |
| 121 |
'category' => $explicit, |
| 122 |
'reason' => TaxCategory::defaultReason( $explicit ), |
| 123 |
'charges_tax' => TaxCategory::isTaxable( $explicit ), |
| 124 |
'automatic' => false, |
| 125 |
]; |
| 126 |
} |
| 127 |
|
| 128 |
if ( ! self::isEnabled() ) { |
| 129 |
return $standard; |
| 130 |
} |
| 131 |
|
| 132 |
$supplier_country = strtoupper( trim( (string) get_option( self::OPTION_SUPPLIER_COUNTRY, '' ) ) ); |
| 133 |
$supplier_vat = trim( (string) get_option( self::OPTION_SUPPLIER_VAT, '' ) ); |
| 134 |
$customer_country = strtoupper( trim( (string) get_post_meta( $id, self::META_CUSTOMER_COUNTRY, true ) ) ); |
| 135 |
$customer_vat = trim( (string) get_post_meta( $id, self::META_CUSTOMER_VAT, true ) ); |
| 136 |
|
| 137 |
// Missing information means we do not know, and "do not know" must resolve |
| 138 |
// to the ordinary rate rather than to a guess that removes tax. |
| 139 |
if ( '' === $supplier_country || '' === $customer_country || '' === $supplier_vat || '' === $customer_vat ) { |
| 140 |
return $standard; |
| 141 |
} |
| 142 |
|
| 143 |
// Same country: an ordinary domestic supply, tax as usual. |
| 144 |
if ( $supplier_country === $customer_country ) { |
| 145 |
return $standard; |
| 146 |
} |
| 147 |
|
| 148 |
$result = $standard; |
| 149 |
|
| 150 |
if ( self::isEu( $supplier_country ) && self::isEu( $customer_country ) ) { |
| 151 |
// Cross-border B2B inside the EU, both parties VAT-registered: the |
| 152 |
// customer accounts for the tax. |
| 153 |
$result = [ |
| 154 |
'category' => TaxCategory::REVERSE_CHARGE, |
| 155 |
'reason' => TaxCategory::defaultReason( TaxCategory::REVERSE_CHARGE ), |
| 156 |
'charges_tax' => false, |
| 157 |
'automatic' => true, |
| 158 |
]; |
| 159 |
} elseif ( self::isEu( $supplier_country ) && ! self::isEu( $customer_country ) ) { |
| 160 |
// Supply leaving the EU. |
| 161 |
$result = [ |
| 162 |
'category' => TaxCategory::EXPORT, |
| 163 |
'reason' => TaxCategory::defaultReason( TaxCategory::EXPORT ), |
| 164 |
'charges_tax' => false, |
| 165 |
'automatic' => true, |
| 166 |
]; |
| 167 |
} |
| 168 |
|
| 169 |
/** |
| 170 |
* Filter the determined tax treatment. |
| 171 |
* |
| 172 |
* Local rules vary and this deliberately encodes only the common case, so |
| 173 |
* an integrator or accountant can correct it. |
| 174 |
* |
| 175 |
* @param array $result Determined treatment. |
| 176 |
* @param object $document Invoice or Quote model. |
| 177 |
*/ |
| 178 |
return (array) apply_filters( 'easy_invoice_tax_treatment', $result, $document ); |
| 179 |
} |
| 180 |
|
| 181 |
/** |
| 182 |
* Should tax be charged on this document at all? |
| 183 |
* |
| 184 |
* @param object $document Invoice or Quote model. |
| 185 |
* @return bool |
| 186 |
*/ |
| 187 |
public static function chargesTax( $document ): bool { |
| 188 |
$treatment = self::forDocument( $document ); |
| 189 |
return ! empty( $treatment['charges_tax'] ); |
| 190 |
} |
| 191 |
|
| 192 |
/** |
| 193 |
* The statement that must appear on the document, if any. |
| 194 |
* |
| 195 |
* @param object $document Invoice or Quote model. |
| 196 |
* @return string |
| 197 |
*/ |
| 198 |
public static function statementFor( $document ): string { |
| 199 |
$treatment = self::forDocument( $document ); |
| 200 |
return TaxCategory::requiresReason( $treatment['category'] ) ? (string) $treatment['reason'] : ''; |
| 201 |
} |
| 202 |
|
| 203 |
/** |
| 204 |
* Add the reverse-charge switch to the tax settings panel. |
| 205 |
* |
| 206 |
* @param array $fields Fields contributed by other code. |
| 207 |
* @return array |
| 208 |
*/ |
| 209 |
public static function registerSetting( $fields ): array { |
| 210 |
$fields = is_array( $fields ) ? $fields : []; |
| 211 |
|
| 212 |
$fields[ self::OPTION_ENABLED ] = [ |
| 213 |
'label' => __( 'Work out reverse charge and exports automatically', 'easy-invoice' ), |
| 214 |
'type' => 'checkbox', |
| 215 |
'default' => 'no', |
| 216 |
'col_span' => 'sm:col-span-6', |
| 217 |
'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' ), |
| 218 |
]; |
| 219 |
|
| 220 |
return $fields; |
| 221 |
} |
| 222 |
|
| 223 |
/** |
| 224 |
* Print the tax statement wherever a document renders its totals. |
| 225 |
* |
| 226 |
* Hooked rather than written into a template: the plugin ships ten invoice |
| 227 |
* designs and six quote designs, plus the PDF layout, and a legally-required |
| 228 |
* statement cannot be present in some of them and missing from others. Every |
| 229 |
* design already fires `easy_invoice_invoice_totals_after_tax`, so one |
| 230 |
* listener covers all of them, including any custom template that follows the |
| 231 |
* same convention. |
| 232 |
* |
| 233 |
* @param object $document Invoice or Quote model. |
| 234 |
* @return void |
| 235 |
*/ |
| 236 |
public static function renderStatement( $document ): void { |
| 237 |
if ( ! is_object( $document ) ) { |
| 238 |
return; |
| 239 |
} |
| 240 |
|
| 241 |
$statement = self::statementFor( $document ); |
| 242 |
if ( '' === $statement ) { |
| 243 |
return; |
| 244 |
} |
| 245 |
|
| 246 |
printf( |
| 247 |
'<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>', |
| 248 |
esc_html( $statement ) |
| 249 |
); |
| 250 |
} |
| 251 |
|
| 252 |
/** |
| 253 |
* Register the statement against the hooks documents fire when rendering totals. |
| 254 |
* |
| 255 |
* @return void |
| 256 |
*/ |
| 257 |
public static function init(): void { |
| 258 |
// The tax settings panel renders a fixed list of keys and exposes |
| 259 |
// `easy_invoice_tax_section_additional_fields` for anything else — the same |
| 260 |
// extension point the Additional Tax addon uses. Adding the option to the |
| 261 |
// settings config array alone does nothing, because that panel never reads it. |
| 262 |
add_filter( 'easy_invoice_tax_section_additional_fields', [ __CLASS__, 'registerSetting' ] ); |
| 263 |
|
| 264 |
add_action( 'easy_invoice_invoice_totals_after_tax', [ __CLASS__, 'renderStatement' ] ); |
| 265 |
add_action( 'easy_invoice_quote_totals_after_tax', [ __CLASS__, 'renderStatement' ] ); |
| 266 |
add_action( 'easy_invoice_pdf_totals_after_tax', [ __CLASS__, 'renderStatementRow' ] ); |
| 267 |
} |
| 268 |
|
| 269 |
/** |
| 270 |
* The same statement, as a table row, for the PDF totals block. |
| 271 |
* |
| 272 |
* @param object $document Invoice or Quote model. |
| 273 |
* @return void |
| 274 |
*/ |
| 275 |
public static function renderStatementRow( $document ): void { |
| 276 |
if ( ! is_object( $document ) ) { |
| 277 |
return; |
| 278 |
} |
| 279 |
|
| 280 |
$statement = self::statementFor( $document ); |
| 281 |
if ( '' === $statement ) { |
| 282 |
return; |
| 283 |
} |
| 284 |
|
| 285 |
printf( |
| 286 |
'<tr><td colspan="2" style="font-size:8.5pt;color:#555555;padding-top:6pt">%s</td></tr>', |
| 287 |
esc_html( $statement ) |
| 288 |
); |
| 289 |
} |
| 290 |
|
| 291 |
/** |
| 292 |
* Is a VAT identifier structurally plausible for its country? |
| 293 |
* |
| 294 |
* A syntax check only — it says nothing about whether the number is |
| 295 |
* registered, which requires VIES. It exists to catch the common case of a |
| 296 |
* number typed with the wrong country prefix or an obviously wrong length |
| 297 |
* before the invoice is sent. |
| 298 |
* |
| 299 |
* @param string $vat VAT identifier, with or without country prefix. |
| 300 |
* @param string $country Expected ISO 3166-1 alpha-2 code. |
| 301 |
* @return bool |
| 302 |
*/ |
| 303 |
public static function looksLikeValidVat( string $vat, string $country = '' ): bool { |
| 304 |
$vat = strtoupper( preg_replace( '/[\s.\-]/', '', $vat ) ); |
| 305 |
if ( '' === $vat ) { |
| 306 |
return false; |
| 307 |
} |
| 308 |
|
| 309 |
// Most EU numbers are the country code followed by 2-13 alphanumerics. |
| 310 |
if ( ! preg_match( '/^([A-Z]{2})([A-Z0-9]{2,13})$/', $vat, $m ) ) { |
| 311 |
return false; |
| 312 |
} |
| 313 |
|
| 314 |
if ( '' !== $country && strtoupper( $country ) !== $m[1] ) { |
| 315 |
// Greece files VAT under EL while its ISO code is GR — the one |
| 316 |
// routine mismatch that is not an error. |
| 317 |
$greece = ( 'GR' === strtoupper( $country ) && 'EL' === $m[1] ); |
| 318 |
if ( ! $greece ) { |
| 319 |
return false; |
| 320 |
} |
| 321 |
} |
| 322 |
|
| 323 |
return true; |
| 324 |
} |
| 325 |
} |
| 326 |
|