CluPilotCloud/app/Services/Billing/TaxTreatment.php

105 lines
4.6 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Models\Customer;
use App\Support\CompanyProfile;
/**
* Which VAT applies to a customer, and why.
*
* Only the two clear-cut cases are decided here:
*
* - a BUSINESS customer whose VERIFIED VAT ID belongs to another EU member
* state pays no VAT to us; the liability shifts to them (reverse charge),
* and the invoice has to say so. Unverified means domestic rate: a
* self-declared string must never be able to zero the tax;
* - everyone else is charged the seller's domestic rate.
*
* "Business" is the answer the customer gave when they were asked, not
* something read out of the VAT field. Reverse charge is a business-to-business
* rule and nothing else, so somebody who has recorded themselves as a private
* person is charged the domestic rate whatever they have typed into `vat_id` —
* previously they were not, and a consumer could zero their own VAT by getting
* a number verified.
*
* A customer nobody has asked keeps the treatment they had: a verified VAT ID
* still earns the reverse charge. That is deliberately NOT the protective
* default used everywhere else in this codebase, and the reason is that the two
* questions have different stakes. Withdrawal is a right, and guessing costs a
* consumer something they cannot get back; VAT is a rate, and flipping every
* unasked business onto 20 % overnight would put our invoices in disagreement
* with contracts that are already running. So this file changes no outcome for
* anyone whose type has never been recorded, and the moment it IS recorded, the
* recorded answer is the only one that counts.
*
* Deliberately NOT handled: cross-border sales to private individuals, which
* are taxed at the customer's own country's rate under the OSS scheme. Doing
* that needs a maintained rate table per member state and a tax adviser's sign
* off, not a guess in a config file — so those customers fall back to the
* domestic rate, which over-collects rather than under-collects.
*/
final readonly class TaxTreatment
{
/** Reverse charge applies inside the EU only. */
private const EU_MEMBER_STATES = [
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES', 'FI', 'FR',
'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO',
'SE', 'SI', 'SK',
];
private function __construct(
public float $rate,
public bool $reverseCharge,
) {}
public static function for(?Customer $customer): self
{
// From the Finance page, not from config: an operator can change the
// rate there and cannot change a config value, and two sources for one
// number is how an invoice ends up disagreeing with the checkout that
// produced it. CompanyProfile falls back to the config value until a
// rate has been saved.
$domestic = CompanyProfile::taxRate() / 100;
// Unverified is the normal state, and it must cost the customer nothing
// more than the domestic rate they would pay anyway — but it must not
// zero the VAT either. Otherwise typing "XX123" is a discount.
if ($customer === null || ! $customer->hasVerifiedVatId()) {
return new self($domestic, false);
}
// Asked of the recorded type, not of the number. Somebody who has said
// they are a private person is a private person, and a VAT ID on their
// record — a former sole trader's, a colleague's, a mistake — must not
// be able to turn that answer over. Only an explicit "consumer" refuses
// here; an unrecorded type falls through and is treated exactly as it
// was before this check existed (see the class comment).
if ($customer->hasRecordedType() && ! $customer->isBusiness()) {
return new self($domestic, false);
}
$vatId = $customer->normalisedVatId();
$country = substr($vatId, 0, 2);
$seller = strtoupper((string) config('provisioning.tax.seller_country', 'AT'));
// Reverse charge is an intra-EU business rule: it needs a member state
// that is not ours, and a number that at least looks like one.
$eligible = in_array($country, self::EU_MEMBER_STATES, true)
&& $country !== $seller
&& preg_match('/^[A-Z]{2}[0-9A-Z]{8,12}$/', $vatId) === 1;
return $eligible ? new self(0.0, true) : new self($domestic, false);
}
public function grossCents(int $netCents): int
{
return (int) round($netCents * (1 + $this->rate));
}
public function percentLabel(): string
{
return rtrim(rtrim(number_format($this->rate * 100, 1, ',', '.'), '0'), ',');
}
}