From 3fb7e5f08c86558a67f60eaff4c8fc518de773f5 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 02:03:29 +0200 Subject: [PATCH] Decide the VAT rate in one place, before the two disagree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Services/Billing/TaxTreatment.php | 8 +++++++- app/Support/CompanyProfile.php | 15 +++++++++++++-- tests/Feature/Billing/FinanceSettingsTest.php | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/Services/Billing/TaxTreatment.php b/app/Services/Billing/TaxTreatment.php index ca0b9ae..3703c4f 100644 --- a/app/Services/Billing/TaxTreatment.php +++ b/app/Services/Billing/TaxTreatment.php @@ -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 diff --git a/app/Support/CompanyProfile.php b/app/Support/CompanyProfile.php index 3cf12ee..710812a 100644 --- a/app/Support/CompanyProfile.php +++ b/app/Support/CompanyProfile.php @@ -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)); } } diff --git a/tests/Feature/Billing/FinanceSettingsTest.php b/tests/Feature/Billing/FinanceSettingsTest.php index badef37..fb5c274 100644 --- a/tests/Feature/Billing/FinanceSettingsTest.php +++ b/tests/Feature/Billing/FinanceSettingsTest.php @@ -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); +});