163 lines
7.1 KiB
PHP
163 lines
7.1 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.
|
|
*
|
|
* ## The price at the till is the domestic gross, for everybody
|
|
*
|
|
* The treatment above decides how a DOCUMENT is split, and it decides nothing
|
|
* about the amount taken from the card. That amount is chargedCents(): the net
|
|
* catalogue figure with the domestic rate on it, and it is the same number for a
|
|
* consumer in Vienna and for a reverse-charge business in Rotterdam. The owner's
|
|
* rule is that the figure on the website is the figure charged, and a Stripe
|
|
* Price cannot ask who is buying — it carries one amount for everyone.
|
|
*
|
|
* So a reverse-charge customer pays that same total and their invoice states the
|
|
* whole of it as net at 0 %. That is a real commercial consequence and it is
|
|
* deliberate: see App\Services\Billing\IssueInvoice.
|
|
*
|
|
* Stripe's own `automatic_tax` is deliberately NOT enabled anywhere. This class
|
|
* is the single tax authority here, knowingly over-collecting on cross-border
|
|
* B2C rather than implementing OSS; letting Stripe compute a second rate would
|
|
* have it charge a German consumer 19 % while our document said 20 %.
|
|
*/
|
|
final readonly class TaxTreatment
|
|
{
|
|
/** Reverse charge applies inside the EU only. */
|
|
public 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,
|
|
) {}
|
|
|
|
/**
|
|
* The seller's own rate, with nobody in particular in mind.
|
|
*
|
|
* 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.
|
|
*
|
|
* This is the ONE place the rate is read. Everything that has to form a
|
|
* gross figure — the price sheet, the Stripe catalogue, the module prices —
|
|
* goes through chargedCents() below rather than multiplying by a percentage
|
|
* of its own.
|
|
*/
|
|
public static function domestic(): self
|
|
{
|
|
return new self(CompanyProfile::taxRate() / 100, false);
|
|
}
|
|
|
|
/**
|
|
* What a net catalogue figure is actually charged as.
|
|
*
|
|
* The number on the website, the amount on the Stripe Price and the total on
|
|
* the invoice are all this one, so it is computed once and here. It takes no
|
|
* customer on purpose: a Stripe Price carries a single amount and cannot ask
|
|
* who is at the checkout, and the owner's rule is that everybody pays the
|
|
* figure they were shown.
|
|
*/
|
|
public static function chargedCents(int $netCents): int
|
|
{
|
|
return self::domestic()->grossCents($netCents);
|
|
}
|
|
|
|
public static function for(?Customer $customer): self
|
|
{
|
|
$domestic = self::domestic();
|
|
|
|
// 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 $domestic;
|
|
}
|
|
|
|
// 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 $domestic;
|
|
}
|
|
|
|
$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) : $domestic;
|
|
}
|
|
|
|
public function grossCents(int $netCents): int
|
|
{
|
|
return (int) round($netCents * (1 + $this->rate));
|
|
}
|
|
|
|
/**
|
|
* The rate in the form the invoice arithmetic works in — 2000 for 20 %.
|
|
*
|
|
* Here rather than at each call site, because the conversion was written out
|
|
* by hand where an invoice was issued and nowhere else, and a second reader
|
|
* of `rate` doing its own multiplication is how two documents end up at two
|
|
* rates.
|
|
*/
|
|
public function basisPoints(): int
|
|
{
|
|
return (int) round($this->rate * 10000);
|
|
}
|
|
|
|
public function percentLabel(): string
|
|
{
|
|
return rtrim(rtrim(number_format($this->rate * 100, 1, ',', '.'), '0'), ',');
|
|
}
|
|
}
|