Decide the VAT rate in one place, before the two disagree
The Finance tab wrote a rate into settings while TaxTreatment kept reading one from config — two sources for one number, added by me two commits ago. It had not bitten yet only because both happened to say 20. TaxTreatment now takes the domestic rate from CompanyProfile, which falls back to the .env-derived config until an operator saves one. Same shape as ProvisioningSettings, which already solves this for the deployment values, and the same reason: an operator can change a settings page and cannot change a config value, so a page that appears to set the rate and does not is worse than no page at all. Reverse charge and the seller country stay where they are. That logic already existed, already handles a verified EU VAT ID correctly, and nothing here needed to know about it — which is exactly why it should not have been duplicated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/granted-plans
parent
4646880b90
commit
3fb7e5f08c
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Support\CompanyProfile;
|
||||
|
||||
/**
|
||||
* Which VAT applies to a customer, and why.
|
||||
|
|
@ -37,7 +38,12 @@ final readonly class TaxTreatment
|
|||
|
||||
public static function for(?Customer $customer): self
|
||||
{
|
||||
$domestic = (float) config('provisioning.tax.rate_percent', 0) / 100;
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -95,9 +95,20 @@ final class CompanyProfile
|
|||
));
|
||||
}
|
||||
|
||||
/** The VAT rate as a percentage, e.g. 20.0. */
|
||||
/**
|
||||
* The domestic VAT rate as a percentage, e.g. 20.0.
|
||||
*
|
||||
* The ONE place it is decided. App\Services\Billing\TaxTreatment reads it
|
||||
* from here rather than from config, because this page can change it and a
|
||||
* config value cannot — and two sources for one number is how an invoice
|
||||
* ends up disagreeing with the checkout that produced it.
|
||||
*
|
||||
* Falls back to the .env-derived config until an operator has saved a rate,
|
||||
* so an installation that already carries CLUPILOT_TAX_PERCENT keeps
|
||||
* working unchanged. Same shape as App\Support\ProvisioningSettings.
|
||||
*/
|
||||
public static function taxRate(): float
|
||||
{
|
||||
return (float) Settings::get('company.tax_rate', 20.0);
|
||||
return (float) Settings::get('company.tax_rate', (float) config('provisioning.tax.rate_percent', 20));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,3 +132,18 @@ function issueOne(InvoiceSeries $series): Invoice
|
|||
'currency' => 'EUR',
|
||||
]);
|
||||
}
|
||||
|
||||
it('decides VAT in one place, so the invoice cannot disagree with the checkout', function () {
|
||||
// Two sources for one number is how a document ends up contradicting the
|
||||
// page that produced it. The Finance tab is the source; config is only the
|
||||
// fallback until a rate has been saved.
|
||||
config(['provisioning.tax.rate_percent' => 20]);
|
||||
|
||||
expect(App\Support\CompanyProfile::taxRate())->toBe(20.0)
|
||||
->and(App\Services\Billing\TaxTreatment::for(null)->rate)->toBe(0.2);
|
||||
|
||||
App\Support\Settings::set('company.tax_rate', 10.0);
|
||||
|
||||
expect(App\Support\CompanyProfile::taxRate())->toBe(10.0)
|
||||
->and(App\Services\Billing\TaxTreatment::for(null)->rate)->toBe(0.1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue