<?php
 namespace App\Services\Billing; use App\Models\Invoice; use App\Models\InvoiceBillingRule; class InvoicingRuleEvaluator { public const ALLOWED_OPERATORS = ['==', '!=', '>', '>=', '<', '<=', 'in', 'not_in', 'contains']; public const ALLOWED_FIELDS = [ 'client.country', 'client.is_company', 'client.client_group_id', 'client.language', 'invoice.total_amount', 'invoice.currency', 'invoice.has_tax', 'invoice.has_product_type', 'invoice.product_ids', 'invoice.product_categories', ]; public function evaluate(Invoice $invoice, string $event): ?InvoiceBillingRule { $context = $this->buildContext($invoice); $normal = InvoiceBillingRule::active()->normal()->forEvent($event)->byPriority()->get(); foreach ($normal as $rule) { if ($this->matchesTree($rule->conditions, $context)) { return $rule->action === InvoiceBillingRule::ACTION_SKIP ? null : $rule; } } $fallback = InvoiceBillingRule::active()->fallback()->forEvent($event)->byPriority()->first(); if ($fallback) { return $fallback->action === InvoiceBillingRule::ACTION_SKIP ? null : $fallback; } return null; } public function buildContext(Invoice $invoice): array { $invoice->loadMissing(['client.country', 'currency', 'items.product']); $productTypes = $invoice->items ->pluck('product.type') ->filter() ->unique() ->values() ->all(); $productCategories = $invoice->items ->pluck('product.category_id') ->filter() ->unique() ->values() ->all(); $productIds = $invoice->items ->pluck('product_id') ->filter() ->values() ->all(); return [ 'client.country' => $invoice->client?->country?->a2_iso, 'client.is_company' => (bool) $invoice->client?->is_company, 'client.client_group_id' => $invoice->client?->client_group_id, 'client.language' => $invoice->client?->language, 'invoice.total_amount' => (float) $invoice->total_amount, 'invoice.currency' => $invoice->currency?->code, 'invoice.has_tax' => (float) $invoice->tax_amount > 0, 'invoice.has_product_type' => $productTypes, 'invoice.product_ids' => $productIds, 'invoice.product_categories' => $productCategories, ]; } public function matchesTree(array $node, array $ctx): bool { if (($node['type'] ?? null) === 'condition') { $field = $node['field'] ?? null; $actual = $field !== null ? ($ctx[$field] ?? null) : null; return $this->compare($actual, $node['op'] ?? '==', $node['value'] ?? null); } $children = $node['children'] ?? []; if (empty($children)) { return ($node['operator'] ?? 'AND') === 'AND'; } $results = array_map(fn ($c) => $this->matchesTree($c, $ctx), $children); return ($node['operator'] ?? 'AND') === 'AND' ? ! in_array(false, $results, true) : in_array(true, $results, true); } public function compare($actual, string $op, $expected): bool { return match ($op) { '==' => $actual == $expected, '!=' => $actual != $expected, '>' => $actual > $expected, '>=' => $actual >= $expected, '<' => $actual < $expected, '<=' => $actual <= $expected, 'in' => is_array($actual) ? ! empty(array_intersect($actual, (array) $expected)) : in_array($actual, (array) $expected, false), 'not_in' => is_array($actual) ? empty(array_intersect($actual, (array) $expected)) : ! in_array($actual, (array) $expected, false), 'contains' => is_array($actual) && in_array($expected, $actual, false), default => false, }; } } 