Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.63% covered (warning)
74.63%
50 / 67
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
LicenseService
74.63% covered (warning)
74.63%
50 / 67
20.00% covered (danger)
20.00%
1 / 5
29.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 loadCatalog
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
6.44
 countActiveTills
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 resolveConcepts
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
7.01
 resolveQuantity
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices;
6
7/**
8 * License pricing catalog: load categories from JSON and resolve billed concepts per period.
9 *
10 * taxRate is global to the license invoice (not per category). unit prices are taxable base (ex-VAT).
11 */
12class LicenseService
13{
14    /** i18n key prefix; resolved labelKey = prefix + category id (e.g. BASE → @place_license_category_BASE). */
15    public const LABEL_KEY_PREFIX = '@place_license_category_';
16
17    private readonly string $catalogPath;
18
19    /** @var array{taxRate: float, categories: list<array<string, mixed>>}|null */
20    private ?array $cachedCatalog = null;
21
22    /**
23     * @param string|null $catalogPath Absolute path to pricing.json; null uses the packaged resource.
24     */
25    public function __construct(?string $catalogPath = null)
26    {
27        $this->catalogPath = $catalogPath
28            ?? (dirname(__DIR__).DIRECTORY_SEPARATOR.'Resources'.DIRECTORY_SEPARATOR.'license'.DIRECTORY_SEPARATOR.'pricing.json');
29    }
30
31    /**
32     * Load (and cache) the pricing catalog from disk.
33     *
34     * @return array{taxRate: float, categories: list<array<string, mixed>>}
35     */
36    public function loadCatalog(): array
37    {
38        if ($this->cachedCatalog !== null) {
39            return $this->cachedCatalog;
40        }
41
42        $content = file_get_contents($this->catalogPath);
43        if ($content === false) {
44            throw new \RuntimeException('Could not read license pricing catalog at '.$this->catalogPath);
45        }
46
47        $decoded = json_decode($content, true);
48        if (!is_array($decoded) || !isset($decoded['taxRate'], $decoded['categories']) || !is_array($decoded['categories'])) {
49            throw new \RuntimeException('Invalid license pricing catalog at '.$this->catalogPath);
50        }
51
52        $this->cachedCatalog = [
53            'taxRate' => (float) $decoded['taxRate'],
54            'categories' => array_values($decoded['categories']),
55        ];
56
57        return $this->cachedCatalog;
58    }
59
60    /**
61     * Count active tills (status AC) in the already-open tenant connection.
62     *
63     * @param \ConxHelper $conx Connection helper with tenant set.
64     * @return int Active till count, or 0 if the query fails.
65     */
66    public function countActiveTills(\ConxHelper $conx): int
67    {
68        if ($conx->tenant === null) {
69            return 0;
70        }
71
72        $rows = [];
73        $res = $conx->tenant->runsql(
74            ['TILL'],
75            "select count(*) as cnt from TILL where status = 'AC'",
76            $rows
77        );
78        if (!$res || count($rows) === 0) {
79            return 0;
80        }
81
82        return (int) ($rows[0]['cnt'] ?? 0);
83    }
84
85    /**
86     * Resolve billed license concepts for a period given an active till count.
87     *
88     * Categories with amount 0 are omitted. taxRate/taxTotal/totalWithTax are invoice-level (not per concept).
89     *
90     * @param int $activeTillCount Active TILL rows (status AC).
91     * @param string $period Period key: 1M, 3M or 1Y.
92     * @param array{taxRate?: float, categories?: list<array<string, mixed>>}|null $catalogOverride Catalog override for tests; null loads from disk.
93     * @return array{
94     *   concepts: list<array{id: string, labelKey: string, kind: string, quantity: int, unitPrice: float, amount: float}>,
95     *   total: float,
96     *   taxRate: float,
97     *   taxTotal: float,
98     *   totalWithTax: float
99     * }
100     */
101    public function resolveConcepts(int $activeTillCount, string $period, ?array $catalogOverride = null): array
102    {
103        $catalog = $catalogOverride ?? $this->loadCatalog();
104        $taxRate = (float) ($catalog['taxRate'] ?? 0.0);
105        $categories = $catalog['categories'] ?? [];
106
107        $concepts = [];
108        $total = 0.0;
109
110        foreach ($categories as $category) {
111            if (!is_array($category)) {
112                continue;
113            }
114
115            $id = (string) ($category['id'] ?? '');
116            $kind = (string) ($category['kind'] ?? '');
117            $prices = $category['prices'] ?? [];
118            if (!is_array($prices) || $id === '' || !array_key_exists($period, $prices)) {
119                continue;
120            }
121
122            $quantity = $this->resolveQuantity($kind, $id, $activeTillCount);
123            $unitPrice = round((float) $prices[$period], 2);
124            $amount = round($unitPrice * $quantity, 2);
125            if ($amount == 0.0) {
126                continue;
127            }
128
129            $concepts[] = [
130                'id' => $id,
131                'labelKey' => self::LABEL_KEY_PREFIX.$id,
132                'kind' => $kind,
133                'quantity' => $quantity,
134                'unitPrice' => $unitPrice,
135                'amount' => $amount,
136            ];
137            $total = round($total + $amount, 2);
138        }
139
140        $taxTotal = round($total * $taxRate / 100.0, 2);
141        $totalWithTax = round($total + $taxTotal, 2);
142
143        return [
144            'concepts' => $concepts,
145            'total' => $total,
146            'taxRate' => $taxRate,
147            'taxTotal' => $taxTotal,
148            'totalWithTax' => $totalWithTax,
149        ];
150    }
151
152    /**
153     * Resolve quantity for a category kind.
154     *
155     * @param string $kind FIXED | QUANTITY.
156     * @param string $id Category id (used for EXTRATILL quantity rule).
157     * @param int $activeTillCount Active till count.
158     */
159    private function resolveQuantity(string $kind, string $id, int $activeTillCount): int
160    {
161        if ($kind === 'FIXED') {
162            return 1;
163        }
164
165        if ($kind === 'QUANTITY' && $id === 'EXTRATILL') {
166            return max(0, $activeTillCount - 1);
167        }
168
169        return 0;
170    }
171}