Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.43% covered (success)
96.43%
108 / 112
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
TaxEngine
96.40% covered (success)
96.40%
107 / 111
57.14% covered (warning)
57.14%
4 / 7
24
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 calculateLineItem
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 calculateDocument
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
3
 resolveCalculator
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
8.12
 applyProRataGlobalDiscount
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 calculateItemLines
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 buildTaxBreakdown
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
6
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\Domain\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Tax\Domain\Model\TaxRate;
12use App\Modules\Tax\Domain\Service\Calculator\ExclusiveVatCalculator;
13use App\Modules\Tax\Domain\Service\Calculator\InclusiveVatCalculator;
14use App\Modules\Tax\Domain\Service\Calculator\MarginVatCalculator;
15use App\Modules\Tax\Domain\Service\Calculator\TaxCalculatorInterface;
16use App\Modules\Tax\Domain\Service\Calculator\WithholdingTaxCalculator;
17use App\Modules\Tax\Domain\ValueObject\DocumentTaxSummary;
18use App\Modules\Tax\Domain\ValueObject\LineTaxResult;
19use App\Modules\Tax\Domain\ValueObject\RoundingMethod;
20use App\Modules\Tax\Domain\ValueObject\RoundingStrategy;
21use App\Modules\Tax\Domain\ValueObject\TaxCalculationMode;
22use App\Modules\Tax\Domain\ValueObject\TaxSummaryEntry;
23
24/**
25 * Domain Service orchestrating tax calculations for line items and full multi-level documents.
26 */
27final class TaxEngine
28{
29    private ExclusiveVatCalculator $exclusiveCalc;
30    private InclusiveVatCalculator $inclusiveCalc;
31    private MarginVatCalculator $marginCalc;
32    private WithholdingTaxCalculator $whtCalc;
33
34    /**
35     * TaxEngine constructor.
36     */
37    public function __construct(
38        ?ExclusiveVatCalculator $exclusiveCalc = null,
39        ?InclusiveVatCalculator $inclusiveCalc = null,
40        ?MarginVatCalculator $marginCalc = null,
41        ?WithholdingTaxCalculator $whtCalc = null,
42    ) {
43        $this->exclusiveCalc = $exclusiveCalc ?? new ExclusiveVatCalculator();
44        $this->inclusiveCalc = $inclusiveCalc ?? new InclusiveVatCalculator();
45        $this->marginCalc = $marginCalc ?? new MarginVatCalculator();
46        $this->whtCalc = $whtCalc ?? new WithholdingTaxCalculator();
47    }
48
49    /**
50     * Calculate tax for a single line item.
51     */
52    public function calculateLineItem(
53        float $unitPrice,
54        float $quantity,
55        float $discountAmount,
56        TaxRate $taxRate,
57        RoundingMethod $roundingMethod = RoundingMethod::HALF_UP,
58        ?float $costPrice = null,
59    ): LineTaxResult {
60        $calculator = $this->resolveCalculator($taxRate->calculationMode);
61
62        return $calculator->calculate(
63            $unitPrice,
64            $quantity,
65            $discountAmount,
66            $taxRate,
67            $roundingMethod,
68            $costPrice
69        );
70    }
71
72    /**
73     * Calculate taxes for an entire document with lines, charges, global discount, and deposits.
74     *
75     * @param array<int, array{
76     *     unit_price: float,
77     *     quantity: float,
78     *     discount: float,
79     *     tax_rate: TaxRate,
80     *     cost_price?: float|null
81     * }> $items
82     * @param float $globalDiscount Global document discount to distribute pro-rata
83     * @param float $shippingNet Shipping cost net base
84     * @param TaxRate|null $shippingTaxRate Optional dedicated tax rate for shipping
85     * @param float $returnableDeposits Total returnable deposits (VAT exempt, added to payment due)
86     * @param RoundingStrategy $strategy Rounding strategy (LINE_LEVEL vs HEADER_LEVEL)
87     * @param RoundingMethod $method Rounding method (HALF_UP vs HALF_EVEN)
88     * @return DocumentTaxSummary
89     */
90    public function calculateDocument(
91        array $items,
92        float $globalDiscount = 0.0,
93        float $shippingNet = 0.0,
94        ?TaxRate $shippingTaxRate = null,
95        float $returnableDeposits = 0.0,
96        RoundingStrategy $strategy = RoundingStrategy::LINE_LEVEL,
97        RoundingMethod $method = RoundingMethod::HALF_UP,
98    ): DocumentTaxSummary {
99        $adjustedItems = $this->applyProRataGlobalDiscount($items, $globalDiscount, $method);
100        $lineResults = $this->calculateItemLines($adjustedItems, $method);
101
102        if ($shippingNet > 0.0 && $shippingTaxRate !== null) {
103            $lineResults[] = $this->calculateLineItem(
104                unitPrice: $shippingNet,
105                quantity: 1.0,
106                discountAmount: 0.0,
107                taxRate: $shippingTaxRate,
108                roundingMethod: $method
109            );
110        }
111
112        $taxBreakdown = $this->buildTaxBreakdown($lineResults, $strategy, $method);
113
114        $totalNet = $method->round(array_sum(array_column($lineResults, 'netAmount')), 2);
115        $totalTax = $method->round(array_sum(array_column($taxBreakdown, 'taxAmount')), 2);
116        $totalGross = $method->round($totalNet + $totalTax, 2);
117        $totalWht = $method->round(array_sum(array_column($lineResults, 'whtAmount')), 2);
118        $totalPaymentDue = $method->round($totalGross - $totalWht + $returnableDeposits, 2);
119
120        return new DocumentTaxSummary(
121            lineResults: $lineResults,
122            taxBreakdown: $taxBreakdown,
123            totalNet: $totalNet,
124            totalTax: $totalTax,
125            totalGross: $totalGross,
126            totalWht: $totalWht,
127            totalPaymentDue: $totalPaymentDue,
128            roundingStrategy: $strategy,
129            roundingMethod: $method
130        );
131    }
132
133    /**
134     * Resolve calculator implementation based on calculation mode.
135     */
136    private function resolveCalculator(TaxCalculationMode $mode): TaxCalculatorInterface
137    {
138        return match ($mode) {
139            TaxCalculationMode::EXCLUSIVE => $this->exclusiveCalc,
140            TaxCalculationMode::INCLUSIVE => $this->inclusiveCalc,
141            TaxCalculationMode::MARGIN => $this->marginCalc,
142            TaxCalculationMode::WHT => $this->whtCalc,
143        };
144    }
145
146    /**
147     * Apply pro-rata global document discount across all line items.
148     *
149     * @param array<int, array{
150     *     unit_price: float,
151     *     quantity: float,
152     *     discount: float,
153     *     tax_rate: TaxRate,
154     *     cost_price?: float|null
155     * }> $items
156     * @return array<int, array{
157     *     unit_price: float,
158     *     quantity: float,
159     *     discount: float,
160     *     tax_rate: TaxRate,
161     *     cost_price?: float|null
162     * }>
163     */
164    private function applyProRataGlobalDiscount(
165        array $items,
166        float $globalDiscount,
167        RoundingMethod $method,
168    ): array {
169        if ($globalDiscount <= 0.0 || empty($items)) {
170            return $items;
171        }
172
173        $rawTotalBase = 0.0;
174        foreach ($items as $item) {
175            $rawTotalBase += max(0.0, ($item['unit_price'] * $item['quantity']) - ($item['discount'] ?? 0.0));
176        }
177
178        if ($rawTotalBase <= 0.0) {
179            return $items;
180        }
181
182        $result = [];
183        foreach ($items as $item) {
184            $itemBase = max(0.0, ($item['unit_price'] * $item['quantity']) - ($item['discount'] ?? 0.0));
185            $share = $itemBase / $rawTotalBase;
186            $allocatedGlobalDiscount = $method->round($globalDiscount * $share, 2);
187            $newDiscount = ($item['discount'] ?? 0.0) + $allocatedGlobalDiscount;
188
189            $result[] = [
190                'unit_price' => $item['unit_price'],
191                'quantity' => $item['quantity'],
192                'discount' => $newDiscount,
193                'tax_rate' => $item['tax_rate'],
194                'cost_price' => $item['cost_price'] ?? null,
195            ];
196        }
197
198        return $result;
199    }
200
201    /**
202     * Calculate line results for all adjusted items.
203     *
204     * @param array<int, array{
205     *     unit_price: float,
206     *     quantity: float,
207     *     discount: float,
208     *     tax_rate: TaxRate,
209     *     cost_price?: float|null
210     * }> $items
211     * @return LineTaxResult[]
212     */
213    private function calculateItemLines(array $items, RoundingMethod $method): array
214    {
215        $lines = [];
216        foreach ($items as $item) {
217            $lines[] = $this->calculateLineItem(
218                unitPrice: $item['unit_price'],
219                quantity: $item['quantity'],
220                discountAmount: $item['discount'] ?? 0.0,
221                taxRate: $item['tax_rate'],
222                roundingMethod: $method,
223                costPrice: $item['cost_price'] ?? null
224            );
225        }
226
227        return $lines;
228    }
229
230    /**
231     * Build aggregated tax breakdown summary per tax code.
232     *
233     * @param LineTaxResult[] $lines
234     * @return TaxSummaryEntry[]
235     */
236    private function buildTaxBreakdown(
237        array $lines,
238        RoundingStrategy $strategy,
239        RoundingMethod $method,
240    ): array {
241        /** @var array<string, array{
242         *     tax_code: string,
243         *     tax_name: string,
244         *     rate_percent: float,
245         *     tax_status: \App\Modules\Tax\Domain\ValueObject\TaxStatus,
246         *     net_base: float,
247         *     tax_amount: float,
248         *     gross_amount: float,
249         *     legal_clause: ?string
250         * }> $groups */
251        $groups = [];
252
253        foreach ($lines as $line) {
254            $code = $line->taxCode;
255            if (!isset($groups[$code])) {
256                $groups[$code] = [
257                    'tax_code' => $line->taxCode,
258                    'tax_name' => $line->taxName,
259                    'rate_percent' => $line->ratePercent,
260                    'tax_status' => $line->taxStatus,
261                    'net_base' => 0.0,
262                    'tax_amount' => 0.0,
263                    'gross_amount' => 0.0,
264                    'legal_clause' => $line->legalClause,
265                ];
266            }
267
268            $groups[$code]['net_base'] += $line->netAmount;
269            $groups[$code]['tax_amount'] += $line->taxAmount;
270            $groups[$code]['gross_amount'] += $line->grossAmount;
271        }
272
273        $breakdown = [];
274        foreach ($groups as $group) {
275            $net = $method->round($group['net_base'], 2);
276            $tax = ($strategy === RoundingStrategy::HEADER_LEVEL && $group['rate_percent'] > 0.0)
277                ? $method->round($net * ($group['rate_percent'] / 100.0), 2)
278                : $method->round($group['tax_amount'], 2);
279            $gross = $method->round($net + $tax, 2);
280
281            $breakdown[] = new TaxSummaryEntry(
282                taxCode: $group['tax_code'],
283                taxName: $group['tax_name'],
284                ratePercent: $group['rate_percent'],
285                taxStatus: $group['tax_status'],
286                netBase: $net,
287                taxAmount: $tax,
288                grossAmount: $gross,
289                legalClause: $group['legal_clause']
290            );
291        }
292
293        return $breakdown;
294    }
295}