Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
117 / 117
100.00% covered (success)
100.00%
16 / 16
CRAP
100.00% covered (success)
100.00%
1 / 1
TaxApplicationService
100.00% covered (success)
100.00%
116 / 116
100.00% covered (success)
100.00%
16 / 16
30
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTaxRates
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTaxRate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveTaxRate
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 deleteTaxRate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTaxGroups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTaxGroup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveTaxGroup
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 deleteTaxGroup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTaxRules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTaxRule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveTaxRule
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 deleteTaxRule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 calculateDocument
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 findExplicitItemTaxRate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 resolveItemTaxRate
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Tax\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Tax\Application\DTO\CalculateDocumentRequest;
12use App\Modules\Tax\Application\DTO\TaxGroupDto;
13use App\Modules\Tax\Application\DTO\TaxRateDto;
14use App\Modules\Tax\Application\DTO\TaxRuleDto;
15use App\Modules\Tax\Domain\Model\TaxGroup;
16use App\Modules\Tax\Domain\Model\TaxGroupItem;
17use App\Modules\Tax\Domain\Model\TaxRate;
18use App\Modules\Tax\Domain\Model\TaxRule;
19use App\Modules\Tax\Domain\Repository\TaxGroupRepositoryInterface;
20use App\Modules\Tax\Domain\Repository\TaxRateRepositoryInterface;
21use App\Modules\Tax\Domain\Repository\TaxRuleRepositoryInterface;
22use App\Modules\Tax\Domain\Service\TaxDeterminationMatrix;
23use App\Modules\Tax\Domain\Service\TaxEngine;
24use App\Modules\Tax\Domain\ValueObject\DocumentTaxSummary;
25use App\Modules\Tax\Domain\ValueObject\RoundingMethod;
26use App\Modules\Tax\Domain\ValueObject\RoundingStrategy;
27use App\Modules\Tax\Domain\ValueObject\TaxCalculationMode;
28use App\Modules\Tax\Domain\ValueObject\TaxStatus;
29use InvalidArgumentException;
30
31/**
32 * Application Service for managing tax domain operations and calculations.
33 */
34final readonly class TaxApplicationService
35{
36    /**
37     * TaxApplicationService constructor.
38     */
39    public function __construct(
40        private TaxRateRepositoryInterface $taxRateRepo,
41        private TaxGroupRepositoryInterface $taxGroupRepo,
42        private TaxRuleRepositoryInterface $taxRuleRepo,
43        private TaxEngine $taxEngine,
44        private TaxDeterminationMatrix $determinationMatrix,
45    ) {
46    }
47
48    /**
49     * List all tax rates.
50     *
51     * @param array<string, mixed> $filters
52     * @return TaxRate[]
53     */
54    public function getTaxRates(array $filters = []): array
55    {
56        return $this->taxRateRepo->findAll($filters);
57    }
58
59    /**
60     * Find single tax rate by ID.
61     */
62    public function getTaxRate(int $id): ?TaxRate
63    {
64        return $this->taxRateRepo->findById($id);
65    }
66
67    /**
68     * Save tax rate from DTO.
69     */
70    public function saveTaxRate(TaxRateDto $dto, int $owner = 1): TaxRate
71    {
72        if ($dto->taxCode === '' || $dto->taxName === '') {
73            throw new InvalidArgumentException('Tax code and name are required.');
74        }
75
76        $taxRate = new TaxRate(
77            id: $dto->id,
78            taxCode: $dto->taxCode,
79            taxName: $dto->taxName,
80            ratePercent: $dto->ratePercent,
81            calculationMode: TaxCalculationMode::from($dto->calculationMode),
82            taxStatus: TaxStatus::from($dto->taxStatus),
83            legalExemptionNote: $dto->legalExemptionNote,
84            fiscalCode: $dto->fiscalCode,
85            isDefault: $dto->isDefault,
86            isActive: $dto->isActive,
87            owner: $owner,
88        );
89
90        return $this->taxRateRepo->save($taxRate);
91    }
92
93    /**
94     * Delete tax rate by ID.
95     */
96    public function deleteTaxRate(int $id): bool
97    {
98        return $this->taxRateRepo->delete($id);
99    }
100
101    /**
102     * List all tax groups.
103     *
104     * @param array<string, mixed> $filters
105     * @return TaxGroup[]
106     */
107    public function getTaxGroups(array $filters = []): array
108    {
109        return $this->taxGroupRepo->findAll($filters);
110    }
111
112    /**
113     * Find tax group by ID.
114     */
115    public function getTaxGroup(int $id): ?TaxGroup
116    {
117        return $this->taxGroupRepo->findById($id);
118    }
119
120    /**
121     * Save tax group from DTO.
122     */
123    public function saveTaxGroup(TaxGroupDto $dto, int $owner = 1): TaxGroup
124    {
125        if ($dto->groupCode === '' || $dto->groupName === '') {
126            throw new InvalidArgumentException('Group code and name are required.');
127        }
128
129        $group = new TaxGroup(
130            id: $dto->id,
131            groupCode: $dto->groupCode,
132            groupName: $dto->groupName,
133            description: $dto->description,
134            isActive: $dto->isActive,
135            items: [],
136            owner: $owner,
137        );
138
139        $savedGroup = $this->taxGroupRepo->save($group);
140        $groupId = $savedGroup->id ?? 0;
141
142        $items = [];
143        foreach ($dto->items as $itemData) {
144            $items[] = new TaxGroupItem(
145                id: null,
146                taxGroupId: $groupId,
147                taxRateId: $itemData['tax_rate_id'],
148                sequenceOrder: $itemData['sequence_order'] ?? 1,
149                isCompound: $itemData['is_compound'] ?? false
150            );
151        }
152
153        $savedGroup->items = $items;
154
155        return $this->taxGroupRepo->save($savedGroup);
156    }
157
158    /**
159     * Delete tax group by ID.
160     */
161    public function deleteTaxGroup(int $id): bool
162    {
163        return $this->taxGroupRepo->delete($id);
164    }
165
166    /**
167     * List all tax determination rules.
168     *
169     * @param array<string, mixed> $filters
170     * @return TaxRule[]
171     */
172    public function getTaxRules(array $filters = []): array
173    {
174        return $this->taxRuleRepo->findAll($filters);
175    }
176
177    /**
178     * Find single tax rule by ID.
179     */
180    public function getTaxRule(int $id): ?TaxRule
181    {
182        return $this->taxRuleRepo->findById($id);
183    }
184
185    /**
186     * Save tax rule from DTO.
187     */
188    public function saveTaxRule(TaxRuleDto $dto, int $owner = 1): TaxRule
189    {
190        if ($dto->ruleName === '' || $dto->targetTaxRateId <= 0) {
191            throw new InvalidArgumentException('Rule name and target tax rate are required.');
192        }
193
194        $rule = new TaxRule(
195            id: $dto->id,
196            ruleName: $dto->ruleName,
197            priority: $dto->priority,
198            customerType: $dto->customerType,
199            geoZone: $dto->geoZone,
200            itemType: $dto->itemType,
201            targetTaxRateId: $dto->targetTaxRateId,
202            isActive: $dto->isActive,
203            owner: $owner,
204        );
205
206        return $this->taxRuleRepo->save($rule);
207    }
208
209    /**
210     * Delete tax rule by ID.
211     */
212    public function deleteTaxRule(int $id): bool
213    {
214        return $this->taxRuleRepo->delete($id);
215    }
216
217    /**
218     * Calculate and simulate document taxes based on requested parameters and determination rules.
219     */
220    public function calculateDocument(CalculateDocumentRequest $request): DocumentTaxSummary
221    {
222        $defaultRate = $this->taxRateRepo->findDefault();
223        $activeRules = $this->taxRuleRepo->findAllActive();
224
225        $items = [];
226        foreach ($request->items as $rawItem) {
227            $taxRate = $this->resolveItemTaxRate($rawItem, $request, $activeRules, $defaultRate);
228            $items[] = [
229                'unit_price' => $rawItem['unit_price'],
230                'quantity' => $rawItem['quantity'],
231                'discount' => $rawItem['discount'] ?? 0.0,
232                'tax_rate' => $taxRate,
233                'cost_price' => $rawItem['cost_price'] ?? null,
234            ];
235        }
236
237        $shippingTaxRate = ($request->shippingTaxRateId !== null && $request->shippingTaxRateId > 0)
238            ? $this->taxRateRepo->findById($request->shippingTaxRateId)
239            : $defaultRate;
240
241        $strategy = RoundingStrategy::tryFrom($request->roundingStrategy) ?? RoundingStrategy::LINE_LEVEL;
242        $method = RoundingMethod::tryFrom($request->roundingMethod) ?? RoundingMethod::HALF_UP;
243
244        return $this->taxEngine->calculateDocument(
245            items: $items,
246            globalDiscount: $request->globalDiscount,
247            shippingNet: $request->shippingNet,
248            shippingTaxRate: $shippingTaxRate,
249            returnableDeposits: $request->returnableDeposits,
250            strategy: $strategy,
251            method: $method
252        );
253    }
254
255    /**
256     * Resolve appropriate TaxRate for a specific line item.
257     *
258     * @param array<string, mixed> $rawItem
259     * @param TaxRule[] $activeRules
260     */
261    private function findExplicitItemTaxRate(array $rawItem): ?TaxRate
262    {
263        if (!empty($rawItem['tax_rate_id'])) {
264            $found = $this->taxRateRepo->findById((int) $rawItem['tax_rate_id']);
265            if ($found !== null) {
266                return $found;
267            }
268        }
269
270        if (!empty($rawItem['tax_code'])) {
271            return $this->taxRateRepo->findByCode((string) $rawItem['tax_code']);
272        }
273
274        return null;
275    }
276
277    private function resolveItemTaxRate(
278        array $rawItem,
279        CalculateDocumentRequest $request,
280        array $activeRules,
281        ?TaxRate $defaultRate
282    ): TaxRate {
283        $explicit = $this->findExplicitItemTaxRate($rawItem);
284        if ($explicit !== null) {
285            return $explicit;
286        }
287
288        $itemType = (string) ($rawItem['item_type'] ?? 'product');
289        $determined = $this->determinationMatrix->determine(
290            rules: $activeRules,
291            customerType: $request->customerType,
292            geoZone: $request->geoZone,
293            itemType: $itemType,
294            fallbackRate: $defaultRate
295        );
296
297        return $determined ?? $defaultRate ?? new TaxRate(
298            id: null,
299            taxCode: 'VAT_23',
300            taxName: 'VAT 23% Standard',
301            ratePercent: 23.0,
302            calculationMode: TaxCalculationMode::EXCLUSIVE,
303            taxStatus: TaxStatus::STANDARD
304        );
305    }
306}