PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.4
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.4
2.4.3 2.4.4 2.4.2 2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 All 60 releases
easy-invoice / includes / Services / TaxTreatment.php

TaxTreatment.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.4, at includes/Services/TaxTreatment.php

326 lines 12.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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