From 3df2a018328192feb5279509866c4205f23b52c7 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Sat, 11 Apr 2026 21:54:39 +0200 Subject: [PATCH] feat(ai): add customer/item/expense ranking tools to the chat assistant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new read-only tools the chat LLM can call to answer "who/what did the most X" questions that previously fell through the cracks: - rank_top_customers — ranks customers by invoiced_total, paid_total, invoice_count, or outstanding_balance over a named time period - rank_top_items — ranks catalog items by quantity_sold or revenue - rank_expense_categories — ranks expense categories by total spend All three share a new ResolvesPeriod trait that centralizes the period-name → [start, end] logic. GetCompanyStatsTool is refactored onto the same trait (identical public schema — the 'all_time' option is only exposed on the new ranking tools, where an unbounded window makes sense; stats over "all time" collapses every record into one giant bucket and is rarely useful). Each tool follows the existing pattern: snake_case name, one-sentence description tuned for LLM tool selection, JSON-schema parameters with injected company scoping (never trusting LLM-supplied company IDs), and JSON-encodable output. outstanding_balance on the customer tool explicitly ignores the period param since it's a current-state snapshot. Multi-company scoping tests lock down the session-authoritative boundary on every new tool. Per-metric ordering tests verify the aggregate queries actually rank correctly, and an ad-hoc-item exclusion test verifies rank_top_items skips invoice lines where item_id is null (free-typed entries that have no catalog row to rank by id). 15 new tests added (tests/Feature/Ai/Tools/); test suite grows from 398 to 413 passing. LLM tool count goes from 9 to 12 — the model will discover the new tools automatically via the function-calling schema with no prompt changes required. --- app/Providers/AiServiceProvider.php | 9 + .../Ai/Tools/Concerns/ResolvesPeriod.php | 65 +++++ app/Services/Ai/Tools/GetCompanyStatsTool.php | 36 +-- .../Ai/Tools/RankExpenseCategoriesTool.php | 107 ++++++++ .../Ai/Tools/RankTopCustomersTool.php | 229 ++++++++++++++++++ app/Services/Ai/Tools/RankTopItemsTool.php | 123 ++++++++++ .../Tools/RankExpenseCategoriesToolTest.php | 111 +++++++++ .../Ai/Tools/RankTopCustomersToolTest.php | 183 ++++++++++++++ .../Feature/Ai/Tools/RankTopItemsToolTest.php | 193 +++++++++++++++ 9 files changed, 1031 insertions(+), 25 deletions(-) create mode 100644 app/Services/Ai/Tools/Concerns/ResolvesPeriod.php create mode 100644 app/Services/Ai/Tools/RankExpenseCategoriesTool.php create mode 100644 app/Services/Ai/Tools/RankTopCustomersTool.php create mode 100644 app/Services/Ai/Tools/RankTopItemsTool.php create mode 100644 tests/Feature/Ai/Tools/RankExpenseCategoriesToolTest.php create mode 100644 tests/Feature/Ai/Tools/RankTopCustomersToolTest.php create mode 100644 tests/Feature/Ai/Tools/RankTopItemsToolTest.php diff --git a/app/Providers/AiServiceProvider.php b/app/Providers/AiServiceProvider.php index 1c26d299..57180a52 100644 --- a/app/Providers/AiServiceProvider.php +++ b/app/Providers/AiServiceProvider.php @@ -9,6 +9,9 @@ use App\Services\Ai\Tools\GetInvoiceTool; use App\Services\Ai\Tools\ListExpenseCategoriesTool; use App\Services\Ai\Tools\ListOverdueInvoicesTool; use App\Services\Ai\Tools\ListRecentPaymentsTool; +use App\Services\Ai\Tools\RankExpenseCategoriesTool; +use App\Services\Ai\Tools\RankTopCustomersTool; +use App\Services\Ai\Tools\RankTopItemsTool; use App\Services\Ai\Tools\SearchCustomersTool; use App\Services\Ai\Tools\SearchInvoicesTool; use App\Services\Ai\Tools\SearchItemsTool; @@ -41,6 +44,12 @@ class AiServiceProvider extends ServiceProvider $registry->register(new ListExpenseCategoriesTool); $registry->register(new GetCompanyStatsTool); + // Ranking tools — group-by aggregates the individual-record + // tools above can't express. + $registry->register(new RankTopCustomersTool); + $registry->register(new RankTopItemsTool); + $registry->register(new RankExpenseCategoriesTool); + return $registry; }); } diff --git a/app/Services/Ai/Tools/Concerns/ResolvesPeriod.php b/app/Services/Ai/Tools/Concerns/ResolvesPeriod.php new file mode 100644 index 00000000..220daf33 --- /dev/null +++ b/app/Services/Ai/Tools/Concerns/ResolvesPeriod.php @@ -0,0 +1,65 @@ + null, + 'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()], + 'this_week' => [$now->copy()->startOfWeek(), $now->copy()->endOfWeek()], + 'this_month' => [$now->copy()->startOfMonth(), $now->copy()->endOfMonth()], + 'last_month' => [ + $now->copy()->subMonthNoOverflow()->startOfMonth(), + $now->copy()->subMonthNoOverflow()->endOfMonth(), + ], + 'this_quarter' => [$now->copy()->startOfQuarter(), $now->copy()->endOfQuarter()], + 'this_year' => [$now->copy()->startOfYear(), $now->copy()->endOfYear()], + 'last_year' => [ + $now->copy()->subYearNoOverflow()->startOfYear(), + $now->copy()->subYearNoOverflow()->endOfYear(), + ], + default => null, + }; + } +} diff --git a/app/Services/Ai/Tools/GetCompanyStatsTool.php b/app/Services/Ai/Tools/GetCompanyStatsTool.php index 8aaf462d..1ec9362b 100644 --- a/app/Services/Ai/Tools/GetCompanyStatsTool.php +++ b/app/Services/Ai/Tools/GetCompanyStatsTool.php @@ -5,7 +5,7 @@ namespace App\Services\Ai\Tools; use App\Models\Expense; use App\Models\Invoice; use App\Models\Payment; -use Carbon\Carbon; +use App\Services\Ai\Tools\Concerns\ResolvesPeriod; /** * Aggregate stats for the current company over a time window. @@ -15,6 +15,14 @@ use Carbon\Carbon; */ class GetCompanyStatsTool extends AiTool { + use ResolvesPeriod; + + /** + * Bounded periods only — stats over `all_time` is almost always useless + * (collapses every record into one giant bucket), so we don't offer it + * here. The ranking tools do expose `all_time` because ranking by totals + * across the full history is a meaningful question. + */ private const PERIODS = [ 'today', 'this_week', @@ -57,6 +65,8 @@ class GetCompanyStatsTool extends AiTool return ['error' => 'invalid_period', 'valid' => self::PERIODS]; } + // Stats are always date-scoped (the enum above excludes `all_time`), + // so rangeFor() is guaranteed to return a non-null pair here. [$start, $end] = $this->rangeFor($period); $invoiceCount = Invoice::query() @@ -98,28 +108,4 @@ class GetCompanyStatsTool extends AiTool 'expenses' => ['count' => $expenseCount, 'total' => $expenseTotal], ]; } - - /** - * @return array{0: Carbon, 1: Carbon} - */ - private function rangeFor(string $period): array - { - $now = Carbon::now(); - - return match ($period) { - 'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()], - 'this_week' => [$now->copy()->startOfWeek(), $now->copy()->endOfWeek()], - 'this_month' => [$now->copy()->startOfMonth(), $now->copy()->endOfMonth()], - 'last_month' => [ - $now->copy()->subMonthNoOverflow()->startOfMonth(), - $now->copy()->subMonthNoOverflow()->endOfMonth(), - ], - 'this_quarter' => [$now->copy()->startOfQuarter(), $now->copy()->endOfQuarter()], - 'this_year' => [$now->copy()->startOfYear(), $now->copy()->endOfYear()], - 'last_year' => [ - $now->copy()->subYearNoOverflow()->startOfYear(), - $now->copy()->subYearNoOverflow()->endOfYear(), - ], - }; - } } diff --git a/app/Services/Ai/Tools/RankExpenseCategoriesTool.php b/app/Services/Ai/Tools/RankExpenseCategoriesTool.php new file mode 100644 index 00000000..7b6a5f7d --- /dev/null +++ b/app/Services/Ai/Tools/RankExpenseCategoriesTool.php @@ -0,0 +1,107 @@ + 'object', + 'properties' => [ + 'period' => [ + 'type' => 'string', + 'enum' => self::ALL_PERIODS, + 'description' => 'Named time window. Use all_time for lifetime totals.', + ], + 'limit' => [ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => self::MAX_LIMIT, + 'description' => 'Max number of categories to return. Default 10.', + ], + ], + 'required' => [], + ]; + } + + public function execute(array $arguments, int $companyId, int $userId): mixed + { + $period = (string) ($arguments['period'] ?? 'all_time'); + if (! in_array($period, self::ALL_PERIODS, true)) { + return ['error' => 'invalid_period', 'valid' => self::ALL_PERIODS]; + } + + $limit = min(max((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), 1), self::MAX_LIMIT); + $range = $this->rangeFor($period); + + $query = Expense::query() + ->where('company_id', $companyId) + ->whereNotNull('expense_category_id') + ->select([ + 'expense_category_id', + DB::raw('SUM(amount) as total_amount'), + DB::raw('COUNT(*) as expense_count'), + ]) + ->groupBy('expense_category_id') + ->orderByDesc('total_amount') + ->limit($limit); + + if ($range !== null) { + $query->whereBetween('expense_date', [$range[0], $range[1]]); + } + + $rows = $query->get()->all(); + + // Batch-load category names in one query. + $categoryIds = array_map(static fn ($row) => (int) $row->expense_category_id, $rows); + $categories = ExpenseCategory::query() + ->whereIn('id', $categoryIds) + ->get() + ->keyBy('id'); + + $ranked = array_map(function ($row) use ($categories): array { + $category = $categories->get((int) $row->expense_category_id); + + return [ + 'expense_category_id' => (int) $row->expense_category_id, + 'name' => $category?->name, + 'total_amount' => (float) $row->total_amount, + 'expense_count' => (int) $row->expense_count, + ]; + }, $rows); + + return [ + 'period' => $period, + 'categories' => $ranked, + ]; + } +} diff --git a/app/Services/Ai/Tools/RankTopCustomersTool.php b/app/Services/Ai/Tools/RankTopCustomersTool.php new file mode 100644 index 00000000..fe7cf0a1 --- /dev/null +++ b/app/Services/Ai/Tools/RankTopCustomersTool.php @@ -0,0 +1,229 @@ + 'object', + 'properties' => [ + 'metric' => [ + 'type' => 'string', + 'enum' => self::METRICS, + 'description' => 'Which metric to rank by.', + ], + 'period' => [ + 'type' => 'string', + 'enum' => self::ALL_PERIODS, + 'description' => 'Named time window. Use all_time for lifetime rankings. Ignored for outstanding_balance (always current).', + ], + 'limit' => [ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => self::MAX_LIMIT, + 'description' => 'Max number of customers to return. Default 5.', + ], + ], + 'required' => ['metric'], + ]; + } + + public function execute(array $arguments, int $companyId, int $userId): mixed + { + $metric = (string) ($arguments['metric'] ?? 'invoiced_total'); + if (! in_array($metric, self::METRICS, true)) { + return ['error' => 'invalid_metric', 'valid' => self::METRICS]; + } + + $period = (string) ($arguments['period'] ?? 'all_time'); + if (! in_array($period, self::ALL_PERIODS, true)) { + return ['error' => 'invalid_period', 'valid' => self::ALL_PERIODS]; + } + + $limit = min(max((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), 1), self::MAX_LIMIT); + + // outstanding_balance is a current-state snapshot; period is meaningless. + $range = $metric === 'outstanding_balance' ? null : $this->rangeFor($period); + + $rows = match ($metric) { + 'invoiced_total' => $this->rankByInvoiceSum($companyId, $range, $limit, 'total'), + 'paid_total' => $this->rankByPaymentSum($companyId, $range, $limit), + 'invoice_count' => $this->rankByInvoiceCount($companyId, $range, $limit), + 'outstanding_balance' => $this->rankByOutstandingBalance($companyId, $limit), + }; + + // Batch-load the customers we're about to return so we can decorate + // each ranking row with name fields. One query regardless of $limit. + $customerIds = array_map(static fn ($row) => (int) $row->customer_id, $rows); + $customers = Customer::query() + ->whereIn('id', $customerIds) + ->get() + ->keyBy('id'); + + $ranked = array_map(function ($row) use ($customers, $metric): array { + $customer = $customers->get((int) $row->customer_id); + + return [ + 'customer_id' => (int) $row->customer_id, + 'name' => $customer?->name, + 'display_name' => $customer?->display_name, + 'company_name' => $customer?->company_name, + 'metric_value' => $metric === 'invoice_count' + ? (int) $row->metric_value + : (float) $row->metric_value, + 'invoice_count' => isset($row->invoice_count) ? (int) $row->invoice_count : null, + ]; + }, $rows); + + return [ + 'metric' => $metric, + 'period' => $metric === 'outstanding_balance' ? 'current' : $period, + 'customers' => $ranked, + ]; + } + + /** + * @param array{0: Carbon, 1: Carbon}|null $range + * @return array + */ + private function rankByInvoiceSum(int $companyId, ?array $range, int $limit, string $sumColumn): array + { + $query = Invoice::query() + ->where('company_id', $companyId) + ->whereNotNull('customer_id') + ->select([ + 'customer_id', + DB::raw("SUM({$sumColumn}) as metric_value"), + DB::raw('COUNT(*) as invoice_count'), + ]) + ->groupBy('customer_id') + ->orderByDesc('metric_value') + ->limit($limit); + + if ($range !== null) { + $query->whereBetween('invoice_date', [$range[0], $range[1]]); + } + + return $query->get()->all(); + } + + /** + * @param array{0: Carbon, 1: Carbon}|null $range + * @return array + */ + private function rankByPaymentSum(int $companyId, ?array $range, int $limit): array + { + $query = Payment::query() + ->where('company_id', $companyId) + ->whereNotNull('customer_id') + ->select([ + 'customer_id', + DB::raw('SUM(amount) as metric_value'), + DB::raw('COUNT(*) as invoice_count'), + ]) + ->groupBy('customer_id') + ->orderByDesc('metric_value') + ->limit($limit); + + if ($range !== null) { + $query->whereBetween('payment_date', [$range[0], $range[1]]); + } + + // Note: `invoice_count` here is actually the payment count for this + // customer within the window — semantically confusing, so drop it. + return array_map(function ($row) { + unset($row->invoice_count); + + return $row; + }, $query->get()->all()); + } + + /** + * @param array{0: Carbon, 1: Carbon}|null $range + * @return array + */ + private function rankByInvoiceCount(int $companyId, ?array $range, int $limit): array + { + $query = Invoice::query() + ->where('company_id', $companyId) + ->whereNotNull('customer_id') + ->select([ + 'customer_id', + DB::raw('COUNT(*) as metric_value'), + DB::raw('COUNT(*) as invoice_count'), + ]) + ->groupBy('customer_id') + ->orderByDesc('metric_value') + ->limit($limit); + + if ($range !== null) { + $query->whereBetween('invoice_date', [$range[0], $range[1]]); + } + + return $query->get()->all(); + } + + /** + * @return array + */ + private function rankByOutstandingBalance(int $companyId, int $limit): array + { + return Invoice::query() + ->where('company_id', $companyId) + ->whereNotNull('customer_id') + ->whereIn('paid_status', ['UNPAID', 'PARTIALLY_PAID']) + ->select([ + 'customer_id', + DB::raw('SUM(due_amount) as metric_value'), + DB::raw('COUNT(*) as invoice_count'), + ]) + ->groupBy('customer_id') + ->orderByDesc('metric_value') + ->limit($limit) + ->get() + ->all(); + } +} diff --git a/app/Services/Ai/Tools/RankTopItemsTool.php b/app/Services/Ai/Tools/RankTopItemsTool.php new file mode 100644 index 00000000..7c0afacc --- /dev/null +++ b/app/Services/Ai/Tools/RankTopItemsTool.php @@ -0,0 +1,123 @@ + 'object', + 'properties' => [ + 'metric' => [ + 'type' => 'string', + 'enum' => self::METRICS, + 'description' => 'Which dimension to rank by.', + ], + 'period' => [ + 'type' => 'string', + 'enum' => self::ALL_PERIODS, + 'description' => 'Named time window. Use all_time for lifetime rankings.', + ], + 'limit' => [ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => self::MAX_LIMIT, + 'description' => 'Max number of items to return. Default 5.', + ], + ], + 'required' => ['metric'], + ]; + } + + public function execute(array $arguments, int $companyId, int $userId): mixed + { + $metric = (string) ($arguments['metric'] ?? 'revenue'); + if (! in_array($metric, self::METRICS, true)) { + return ['error' => 'invalid_metric', 'valid' => self::METRICS]; + } + + $period = (string) ($arguments['period'] ?? 'all_time'); + if (! in_array($period, self::ALL_PERIODS, true)) { + return ['error' => 'invalid_period', 'valid' => self::ALL_PERIODS]; + } + + $limit = min(max((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), 1), self::MAX_LIMIT); + $range = $this->rangeFor($period); + $orderColumn = $metric === 'revenue' ? 'total_revenue' : 'total_quantity'; + + $query = InvoiceItem::query() + ->join('invoices', 'invoice_items.invoice_id', '=', 'invoices.id') + ->where('invoices.company_id', $companyId) + ->whereNotNull('invoice_items.item_id') + ->select([ + 'invoice_items.item_id', + DB::raw('SUM(invoice_items.quantity) as total_quantity'), + DB::raw('SUM(invoice_items.total) as total_revenue'), + ]) + ->groupBy('invoice_items.item_id') + ->orderByDesc($orderColumn) + ->limit($limit); + + if ($range !== null) { + $query->whereBetween('invoices.invoice_date', [$range[0], $range[1]]); + } + + $rows = $query->get()->all(); + + // Batch-load item names in one query. + $itemIds = array_map(static fn ($row) => (int) $row->item_id, $rows); + $items = Item::query() + ->whereIn('id', $itemIds) + ->get() + ->keyBy('id'); + + $ranked = array_map(function ($row) use ($items): array { + $item = $items->get((int) $row->item_id); + + return [ + 'item_id' => (int) $row->item_id, + 'name' => $item?->name, + 'quantity_sold' => (float) $row->total_quantity, + 'revenue' => (float) $row->total_revenue, + ]; + }, $rows); + + return [ + 'metric' => $metric, + 'period' => $period, + 'items' => $ranked, + ]; + } +} diff --git a/tests/Feature/Ai/Tools/RankExpenseCategoriesToolTest.php b/tests/Feature/Ai/Tools/RankExpenseCategoriesToolTest.php new file mode 100644 index 00000000..94b038f5 --- /dev/null +++ b/tests/Feature/Ai/Tools/RankExpenseCategoriesToolTest.php @@ -0,0 +1,111 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); +}); + +test('rank_expense_categories orders categories by total spend', function () { + $company = Company::first(); + + $software = ExpenseCategory::factory()->create(['company_id' => $company->id, 'name' => 'Software']); + $travel = ExpenseCategory::factory()->create(['company_id' => $company->id, 'name' => 'Travel']); + $office = ExpenseCategory::factory()->create(['company_id' => $company->id, 'name' => 'Office Supplies']); + + // Software: 2 expenses totalling 30000 + Expense::factory()->create([ + 'company_id' => $company->id, 'expense_category_id' => $software->id, 'amount' => 20000, + ]); + Expense::factory()->create([ + 'company_id' => $company->id, 'expense_category_id' => $software->id, 'amount' => 10000, + ]); + + // Travel: 1 expense, 15000 + Expense::factory()->create([ + 'company_id' => $company->id, 'expense_category_id' => $travel->id, 'amount' => 15000, + ]); + + // Office: 1 expense, 2000 + Expense::factory()->create([ + 'company_id' => $company->id, 'expense_category_id' => $office->id, 'amount' => 2000, + ]); + + $result = (new RankExpenseCategoriesTool)->execute([], $company->id, 1); + + // Grab only the three we created + $byName = collect($result['categories'])->keyBy('name'); + + expect($byName['Software']['total_amount'])->toBe(30000.0); + expect($byName['Software']['expense_count'])->toBe(2); + expect($byName['Travel']['total_amount'])->toBe(15000.0); + expect($byName['Office Supplies']['total_amount'])->toBe(2000.0); + + // Ordering: Software (30k) > Travel (15k) > Office (2k). First three positions should be ours. + $names = collect($result['categories'])->pluck('name')->values()->all(); + $idxSoftware = array_search('Software', $names, true); + $idxTravel = array_search('Travel', $names, true); + $idxOffice = array_search('Office Supplies', $names, true); + expect($idxSoftware)->toBeLessThan($idxTravel); + expect($idxTravel)->toBeLessThan($idxOffice); +}); + +test('rank_expense_categories does not leak across companies', function () { + $companyA = Company::first(); + $companyB = Company::factory()->create(); + + $catA = ExpenseCategory::factory()->create(['company_id' => $companyA->id, 'name' => 'Company A Cat']); + $catB = ExpenseCategory::factory()->create(['company_id' => $companyB->id, 'name' => 'Company B Cat']); + + Expense::factory()->create([ + 'company_id' => $companyA->id, 'expense_category_id' => $catA->id, 'amount' => 1000, + ]); + Expense::factory()->create([ + 'company_id' => $companyB->id, 'expense_category_id' => $catB->id, 'amount' => 999999, + ]); + + // Call with companyA — should only see A's category + $result = (new RankExpenseCategoriesTool)->execute([], $companyA->id, 1); + + expect(collect($result['categories'])->pluck('name')) + ->toContain('Company A Cat') + ->not->toContain('Company B Cat'); +}); + +test('rank_expense_categories rejects an invalid period', function () { + $company = Company::first(); + + $result = (new RankExpenseCategoriesTool)->execute( + ['period' => 'next_century'], + $company->id, + 1, + ); + + expect($result)->toHaveKey('error', 'invalid_period'); +}); + +test('rank_expense_categories respects the limit parameter', function () { + $company = Company::first(); + + // Create 6 categories each with one expense + for ($i = 0; $i < 6; $i++) { + $cat = ExpenseCategory::factory()->create([ + 'company_id' => $company->id, + 'name' => "TestCat {$i}", + ]); + Expense::factory()->create([ + 'company_id' => $company->id, + 'expense_category_id' => $cat->id, + 'amount' => 1000 * ($i + 1), + ]); + } + + $result = (new RankExpenseCategoriesTool)->execute(['limit' => 3], $company->id, 1); + + expect($result['categories'])->toHaveCount(3); +}); diff --git a/tests/Feature/Ai/Tools/RankTopCustomersToolTest.php b/tests/Feature/Ai/Tools/RankTopCustomersToolTest.php new file mode 100644 index 00000000..daed6425 --- /dev/null +++ b/tests/Feature/Ai/Tools/RankTopCustomersToolTest.php @@ -0,0 +1,183 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); +}); + +test('rank_top_customers ranks by invoiced_total correctly', function () { + $company = Company::first(); + + $bigSpender = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Big Spender']); + $midSpender = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Mid Spender']); + $smallSpender = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Small Spender']); + + // Big: 2 invoices totalling 100000. Mid: 1 invoice totalling 50000. Small: 1 invoice totalling 10000. + Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $bigSpender->id, 'total' => 60000]); + Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $bigSpender->id, 'total' => 40000]); + Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $midSpender->id, 'total' => 50000]); + Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $smallSpender->id, 'total' => 10000]); + + $result = (new RankTopCustomersTool)->execute( + ['metric' => 'invoiced_total', 'limit' => 3], + $company->id, + 1, + ); + + expect($result['metric'])->toBe('invoiced_total'); + expect($result['customers'])->toHaveCount(3); + expect($result['customers'][0]['name'])->toBe('Big Spender'); + expect($result['customers'][0]['metric_value'])->toBe(100000.0); + expect($result['customers'][0]['invoice_count'])->toBe(2); + expect($result['customers'][1]['name'])->toBe('Mid Spender'); + expect($result['customers'][2]['name'])->toBe('Small Spender'); +}); + +test('rank_top_customers ranks by invoice_count correctly', function () { + $company = Company::first(); + + $busy = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Busy Bee']); + $quiet = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Quiet One']); + + // Busy has 5 tiny invoices, Quiet has 1 big one. + for ($i = 0; $i < 5; $i++) { + Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $busy->id, 'total' => 100]); + } + Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $quiet->id, 'total' => 99999]); + + $result = (new RankTopCustomersTool)->execute( + ['metric' => 'invoice_count'], + $company->id, + 1, + ); + + expect($result['customers'][0]['name'])->toBe('Busy Bee'); + expect($result['customers'][0]['metric_value'])->toBe(5); + expect($result['customers'][1]['name'])->toBe('Quiet One'); + expect($result['customers'][1]['metric_value'])->toBe(1); +}); + +test('rank_top_customers ranks by outstanding_balance and ignores period', function () { + $company = Company::first(); + + $debtor = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Owes A Lot']); + $upToDate = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Up To Date']); + + // Debtor has an unpaid invoice with 75000 due_amount. + Invoice::factory()->create([ + 'company_id' => $company->id, + 'customer_id' => $debtor->id, + 'total' => 100000, + 'due_amount' => 75000, + 'paid_status' => 'PARTIALLY_PAID', + ]); + + // Up To Date has only a fully-paid invoice — should NOT appear. + Invoice::factory()->create([ + 'company_id' => $company->id, + 'customer_id' => $upToDate->id, + 'total' => 50000, + 'due_amount' => 0, + 'paid_status' => 'PAID', + ]); + + // Pass an obviously wrong period — outstanding_balance should ignore it. + $result = (new RankTopCustomersTool)->execute( + ['metric' => 'outstanding_balance', 'period' => 'today'], + $company->id, + 1, + ); + + expect($result['period'])->toBe('current'); + expect(collect($result['customers'])->pluck('name')) + ->toContain('Owes A Lot') + ->not->toContain('Up To Date'); +}); + +test('rank_top_customers respects the limit parameter and default', function () { + $company = Company::first(); + + // 7 customers, each with one invoice + for ($i = 0; $i < 7; $i++) { + $c = Customer::factory()->create(['company_id' => $company->id]); + Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $c->id, 'total' => 1000]); + } + + $tool = new RankTopCustomersTool; + + // Default limit is 5 + $result = $tool->execute(['metric' => 'invoiced_total'], $company->id, 1); + expect($result['customers'])->toHaveCount(5); + + // Explicit limit 2 + $result = $tool->execute(['metric' => 'invoiced_total', 'limit' => 2], $company->id, 1); + expect($result['customers'])->toHaveCount(2); + + // Over-the-cap limit is clamped to 20 + $result = $tool->execute(['metric' => 'invoiced_total', 'limit' => 999], $company->id, 1); + expect(count($result['customers']))->toBeLessThanOrEqual(20); +}); + +test('rank_top_customers does not leak across companies', function () { + $companyA = Company::first(); + $companyB = Company::factory()->create(); + + $customerA = Customer::factory()->create(['company_id' => $companyA->id, 'name' => 'Company A Cust']); + $customerB = Customer::factory()->create(['company_id' => $companyB->id, 'name' => 'Company B Cust']); + + Invoice::factory()->create(['company_id' => $companyA->id, 'customer_id' => $customerA->id, 'total' => 5000]); + Invoice::factory()->create(['company_id' => $companyB->id, 'customer_id' => $customerB->id, 'total' => 999999]); + + // Call with companyA — should only see companyA's customer + $result = (new RankTopCustomersTool)->execute( + ['metric' => 'invoiced_total'], + $companyA->id, + 1, + ); + + expect(collect($result['customers'])->pluck('name')) + ->toContain('Company A Cust') + ->not->toContain('Company B Cust'); +}); + +test('rank_top_customers rejects an invalid metric', function () { + $company = Company::first(); + + $result = (new RankTopCustomersTool)->execute( + ['metric' => 'not_a_metric'], + $company->id, + 1, + ); + + expect($result)->toHaveKey('error', 'invalid_metric'); +}); + +test('rank_top_customers ranks by paid_total using payment records', function () { + $company = Company::first(); + + $topPayer = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Top Payer']); + $lowPayer = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Low Payer']); + + // Top Payer has 2 payments totalling 80000. Low Payer has 1 payment for 20000. + Payment::factory()->create(['company_id' => $company->id, 'customer_id' => $topPayer->id, 'amount' => 50000]); + Payment::factory()->create(['company_id' => $company->id, 'customer_id' => $topPayer->id, 'amount' => 30000]); + Payment::factory()->create(['company_id' => $company->id, 'customer_id' => $lowPayer->id, 'amount' => 20000]); + + $result = (new RankTopCustomersTool)->execute( + ['metric' => 'paid_total'], + $company->id, + 1, + ); + + expect($result['customers'][0]['name'])->toBe('Top Payer'); + expect($result['customers'][0]['metric_value'])->toBe(80000.0); + expect($result['customers'][1]['name'])->toBe('Low Payer'); + expect($result['customers'][1]['metric_value'])->toBe(20000.0); +}); diff --git a/tests/Feature/Ai/Tools/RankTopItemsToolTest.php b/tests/Feature/Ai/Tools/RankTopItemsToolTest.php new file mode 100644 index 00000000..5b049632 --- /dev/null +++ b/tests/Feature/Ai/Tools/RankTopItemsToolTest.php @@ -0,0 +1,193 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); +}); + +/** + * Helper: create an Item with a known name/price for this company. The Item + * factory has known bugs (cascades RecurringInvoice, hardcoded creator_id), + * so we use direct ::create() for determinism. + */ +function makeItem(int $companyId, string $name, int $price): Item +{ + $unit = Unit::where('company_id', $companyId)->firstOrFail(); + + return Item::create([ + 'name' => $name, + 'description' => $name.' description', + 'price' => $price, + 'company_id' => $companyId, + 'unit_id' => $unit->id, + 'currency_id' => 1, + ]); +} + +test('rank_top_items ranks by revenue correctly', function () { + $company = Company::first(); + $customer = Customer::factory()->create(['company_id' => $company->id]); + + $premium = makeItem($company->id, 'Premium Widget', 10000); + $standard = makeItem($company->id, 'Standard Widget', 5000); + $budget = makeItem($company->id, 'Budget Widget', 1000); + + $invoice = Invoice::factory()->create([ + 'company_id' => $company->id, + 'customer_id' => $customer->id, + ]); + + // Premium: 2 × 10000 = 20000 + // Standard: 3 × 5000 = 15000 + // Budget: 5 × 1000 = 5000 + InvoiceItem::factory()->create([ + 'invoice_id' => $invoice->id, 'company_id' => $company->id, + 'item_id' => $premium->id, 'quantity' => 2, 'price' => 10000, 'total' => 20000, + ]); + InvoiceItem::factory()->create([ + 'invoice_id' => $invoice->id, 'company_id' => $company->id, + 'item_id' => $standard->id, 'quantity' => 3, 'price' => 5000, 'total' => 15000, + ]); + InvoiceItem::factory()->create([ + 'invoice_id' => $invoice->id, 'company_id' => $company->id, + 'item_id' => $budget->id, 'quantity' => 5, 'price' => 1000, 'total' => 5000, + ]); + + $result = (new RankTopItemsTool)->execute( + ['metric' => 'revenue'], + $company->id, + 1, + ); + + expect($result['metric'])->toBe('revenue'); + expect($result['items'][0]['name'])->toBe('Premium Widget'); + expect($result['items'][0]['revenue'])->toBe(20000.0); + expect($result['items'][1]['name'])->toBe('Standard Widget'); + expect($result['items'][2]['name'])->toBe('Budget Widget'); +}); + +test('rank_top_items ranks by quantity_sold correctly', function () { + $company = Company::first(); + $customer = Customer::factory()->create(['company_id' => $company->id]); + + $bulky = makeItem($company->id, 'Bulk Good', 100); + $pricey = makeItem($company->id, 'Pricey Good', 50000); + + $invoice = Invoice::factory()->create([ + 'company_id' => $company->id, + 'customer_id' => $customer->id, + ]); + + // Bulky: sold 50 units at 100 each = 5000 revenue + // Pricey: sold 1 unit at 50000 = 50000 revenue + InvoiceItem::factory()->create([ + 'invoice_id' => $invoice->id, 'company_id' => $company->id, + 'item_id' => $bulky->id, 'quantity' => 50, 'price' => 100, 'total' => 5000, + ]); + InvoiceItem::factory()->create([ + 'invoice_id' => $invoice->id, 'company_id' => $company->id, + 'item_id' => $pricey->id, 'quantity' => 1, 'price' => 50000, 'total' => 50000, + ]); + + // By quantity: Bulky wins (50 > 1) + $result = (new RankTopItemsTool)->execute( + ['metric' => 'quantity_sold'], + $company->id, + 1, + ); + + expect($result['items'][0]['name'])->toBe('Bulk Good'); + expect($result['items'][0]['quantity_sold'])->toBe(50.0); + + // By revenue: Pricey wins (50000 > 5000) + $result = (new RankTopItemsTool)->execute( + ['metric' => 'revenue'], + $company->id, + 1, + ); + + expect($result['items'][0]['name'])->toBe('Pricey Good'); +}); + +test('rank_top_items excludes ad-hoc line items with null item_id', function () { + $company = Company::first(); + $customer = Customer::factory()->create(['company_id' => $company->id]); + + $cataloged = makeItem($company->id, 'Real Item', 1000); + + $invoice = Invoice::factory()->create([ + 'company_id' => $company->id, + 'customer_id' => $customer->id, + ]); + + // Cataloged item on the invoice + InvoiceItem::factory()->create([ + 'invoice_id' => $invoice->id, 'company_id' => $company->id, + 'item_id' => $cataloged->id, 'quantity' => 1, 'price' => 1000, 'total' => 1000, + ]); + + // Ad-hoc line without a catalog item — simulate via direct ::create + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'company_id' => $company->id, + 'item_id' => null, + 'name' => 'Ad hoc typed line', + 'price' => 9999, + 'quantity' => 1, + 'total' => 9999, + 'discount' => 0, + 'discount_val' => 0, + 'discount_type' => 'fixed', + 'tax' => 0, + 'base_price' => 9999, + 'base_total' => 9999, + 'base_discount_val' => 0, + 'base_tax' => 0, + 'exchange_rate' => 1, + ]); + + $result = (new RankTopItemsTool)->execute( + ['metric' => 'revenue'], + $company->id, + 1, + ); + + // Only the cataloged item should appear — ad-hoc line is excluded by whereNotNull. + expect($result['items'])->toHaveCount(1); + expect($result['items'][0]['name'])->toBe('Real Item'); +}); + +test('rank_top_items does not leak across companies', function () { + $companyA = Company::first(); + $companyB = Company::factory()->create(); + + $itemA = makeItem($companyA->id, 'A Only Item', 1000); + + $customerA = Customer::factory()->create(['company_id' => $companyA->id]); + $invoiceA = Invoice::factory()->create([ + 'company_id' => $companyA->id, + 'customer_id' => $customerA->id, + ]); + InvoiceItem::factory()->create([ + 'invoice_id' => $invoiceA->id, 'company_id' => $companyA->id, + 'item_id' => $itemA->id, 'quantity' => 1, 'price' => 1000, 'total' => 1000, + ]); + + // Call with companyB — should see no items at all + $result = (new RankTopItemsTool)->execute( + ['metric' => 'revenue'], + $companyB->id, + 1, + ); + + expect($result['items'])->toBeEmpty(); +});