dolibarr/htdocs/ai/tools/invoices.class.php
Pichinov-Jose 1aeec05d80
Fix: MCP server propagates authenticated service user to tools (#38294)
The MCP server (htdocs/ai/server/mcp_server.php) loads a service user
from AI_MCP_USER_ID but does not expose it as the PHP global $user.
Several tool classes call 'global $user' to identify the caller --
they therefore see no authenticated user in HTTP MCP context (no PHP
web session exists) and return 'User not authenticated' even though
authentication via X-API-Key/Bearer has already succeeded.

This breaks roughly 40% of the MCP tools shipped by the module when
called from external clients (Claude Desktop Custom Connectors,
Claude Code CLI, MCP Inspector, custom scripts).

Two complementary fixes applied:

1. mcp_server.php: after successfully loading the service user,
   assign it to $GLOBALS['user'] and the local $user variable. This
   acts as a safety net for any tool (current or third-party) that
   relies on the legacy 'global $user' pattern.

2. Three tool classes harmonize their constructor signatures with
   the McpHandler contract -- new $className($db, $user, $conf):
   - ToolCrudObjects (htdocs/ai/tools/crud_objects.class.php)
   - ToolInvoices (htdocs/ai/tools/invoices.class.php)
   - ToolReports (htdocs/ai/tools/reports.class.php)

   These now accept $user via constructor (matching the pattern
   already used in ToolThirdParty, ToolCategories, ToolProducts)
   and store it as $this->user. Methods that previously called
   'global $user' (execute, validateInvoice, payInvoice) now use
   the injected $this->user, with a fallback to the global for
   web-session contexts.

Tested with:
- create_other_document (proposal, supplier_invoice): works
- search_invoice, validate_invoice, pay_invoice: works
- get_sales_report, get_purchase_report: works
- AI Assistant web UI: unchanged, no regression
2026-07-13 06:57:42 +02:00

588 lines
16 KiB
PHP

<?php
/* Copyright (C) 2026 Laurent Destailleur <eldy@users.sourceforge.net>
* Copyright (C) 2026 Nick Fragoulis
* Copyright (C) 2026 Jose Martinez <jose.martinez@pichinov.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY, without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* \file htdocs/ai/tools/invoices.php
* \ingroup ai
* \brief MCP Server tool for Invoice management.
*/
require_once DOL_DOCUMENT_ROOT . '/compta/facture/class/facture.class.php';
require_once DOL_DOCUMENT_ROOT . '/societe/class/societe.class.php';
require_once DOL_DOCUMENT_ROOT . '/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT . '/compta/paiement/class/paiement.class.php';
require_once DOL_DOCUMENT_ROOT . '/compta/bank/class/account.class.php';
require_once DOL_DOCUMENT_ROOT . '/core/lib/date.lib.php';
/**
* Class ToolInvoices
*
* Provides various tools related to Dolibarr invoices.
*/
class ToolInvoices extends McpTool
{
/**
* Constructor
*
* Aligned with McpHandler's instantiation contract: new $className($db, $user, $conf).
*
* @param DoliDB $db Database handler
* @param User|null $user Service user provided by McpHandler (from AI_MCP_USER_ID)
* @param Conf|null $conf Dolibarr config (optional)
*/
public function __construct(DoliDB $db, $user = null, $conf = null)
{
$this->db = $db;
$this->user = $user;
if ($conf !== null) {
$this->conf = $conf;
}
}
/**
* Return the authenticated user, preferring DI ($this->user) and falling back to global $user.
* In HTTP MCP context there is no PHP web session — only the service user injected via DI.
*
* @return User|null
*/
private function getUser()
{
// is_object() instead of empty() to avoid PHPStan flagging empty() on a
// non-nullable typed parent property as unreachable.
if (is_object($this->user) && !empty($this->user->id)) {
return $this->user;
}
global $user;
return is_object($user) && !empty($user->id) ? $user : null;
}
/**
* Returns an array of tool definitions, including name, description, and input schema.
*
* @return list<array<string, mixed>> Array of tool definitions.
*/
public function getDefinitions(): array
{
return [
[
"name" => "search_invoice",
"description" => "Search for invoices. By default, lists UNPAID invoices. Excludes drafts.",
"inputSchema" => [
"type" => "object",
"properties" => [
"customer" => [
"type" => "string",
"description" => "Optional: Customer name."
],
"status" => [
"type" => "string",
"enum" => ["unpaid", "paid", "draft", "all"],
"description" => "Filter by status. Default is 'unpaid'. 'all' shows history but excludes drafts.",
"default" => "unpaid"
],
"limit" => [
"type" => "integer",
"default" => 10
]
]
]
],
[
"name" => "get_invoice",
"description" => "Get details of a specific invoice by ID or Reference.",
"inputSchema" => [
"type" => "object",
"properties" => [
"ref" => ["type" => "string", "description" => "Invoice Ref (e.g. FA2401-001)"],
"id" => ["type" => "integer", "description" => "Invoice ID"]
],
"oneOf" => [
["required" => ["ref"]],
["required" => ["id"]]
]
]
],
[
"name" => "validate_invoice",
"description" => "Validate a draft invoice.",
"inputSchema" => [
"type" => "object",
"properties" => [
"invoice" => ["type" => "string", "description" => "Invoice ID or Ref."]
],
"required" => ["invoice"]
]
],
[
"name" => "pay_invoice",
"description" => "Register a payment for an invoice.",
"inputSchema" => [
"type" => "object",
"properties" => [
"invoice" => ["type" => "string", "description" => "Invoice ID or Reference."],
"amount" => ["type" => "number", "description" => "Amount to pay. Defaults to full remaining."],
"payment_mode" => ["type" => "string", "description" => "Code (VIR, CB, LIQ)."],
"bank_account" => ["type" => "string", "description" => "Bank Account Name/Ref."]
],
"required" => ["invoice"]
]
]
];
}
/**
* Return categories this tool belongs to.
* Used by the intent parser to filter available tools.
*
* @return array<string> List of categories (e.g., ['billing', 'commercial'])
*/
public function getCategories(): array
{
return ['billing'];
}
/**
* Executes the requested tool function based on its name.
*
* @param string $name The name of the tool to execute.
* @param array<string, mixed> $args The arguments for the tool (key-value pairs).
* @return mixed The result of the tool execution (usually an array) or an error array.
*/
public function execute(string $name, array $args)
{
switch ($name) {
case 'search_invoice':
case 'search_invoices':
return $this->searchInvoices($args);
case 'get_invoice':
return $this->getInvoice($args);
case 'validate_invoice':
return $this->validateInvoice($args);
case 'pay_invoice':
return $this->payInvoice($args);
default:
return ["error" => "Tool function '$name' not found."];
}
}
/**
* Search invoices based on filters.
*
* @param array<string, mixed> $args Input filters (limit, status, customer)
*
* @return array<int, array<string, mixed>>|array<string, string>
*/
private function searchInvoices($args)
{
$limit = isset($args['limit']) ? (int) $args['limit'] : 10;
$status = isset($args['status']) ? $args['status'] : 'unpaid';
// Safety fallback
if ($limit <= 0) {
$limit = 5;
}
if ($limit > 1000) {
dol_syslog("Search DB Error: Too many record requested", LOG_ERR);
return ["error" => "DB Error"];
}
$sql = "SELECT f.rowid, f.ref, f.total_ttc, f.fk_statut, f.paye, f.datef, s.nom
FROM " . MAIN_DB_PREFIX . "facture as f
LEFT JOIN " . MAIN_DB_PREFIX . "societe as s ON f.fk_soc = s.rowid
WHERE f.entity IN (" . getEntity('facture') . ")";
// Status filtering
if ($status === 'draft') {
// Explicitly asking for drafts
$sql .= " AND f.fk_statut = 0";
} elseif ($status === 'paid') {
// Fully paid
$sql .= " AND f.fk_statut = 2";
} elseif ($status === 'all') {
// Valid invoices (Unpaid + Paid). EXCLUDES Drafts (0) and Abandoned (3)
$sql .= " AND f.fk_statut IN (1, 2)";
} else {
// Default: 'unpaid'
// In Dolibarr: fk_statut=1 means Validated but not fully paid.
$sql .= " AND f.fk_statut = 1 AND f.paye = 0";
}
// Customer Filter
if (!empty($args['customer'])) {
$cust = $this->findCustomer($args['customer']);
if (!is_array($cust)) {
$sql .= " AND f.fk_soc = " . ((int) $cust->id);
} else {
$sql .= " AND s.nom LIKE '%" . $this->db->escape($args['customer']) . "%'";
}
}
$sql .= " ORDER BY f.datef DESC LIMIT " . ((int) $limit);
$resql = $this->db->query($sql);
$list = [];
if ($resql) {
while ($r = $this->db->fetch_object($resql)) {
// Double check to ensure no PROV/Drafts slip through unless asked
if ($status !== 'draft' && $r->fk_statut == 0) {
continue;
}
$ref = ($r->fk_statut == 0 ? "(PROV" . $r->rowid . ")" : $r->ref);
// Calculate Status Label
$statusLabel = "Unknown";
if ($r->fk_statut == 0) {
$statusLabel = "Draft";
} elseif ($r->fk_statut == 1) {
$statusLabel = "Unpaid";
} elseif ($r->fk_statut == 2) {
$statusLabel = "Paid";
} elseif ($r->fk_statut == 3) {
$statusLabel = "Abandoned";
}
$list[] = [
"ref" => $ref,
"date" => dol_print_date($this->db->jdate($r->datef), 'day'),
"customer" => $r->nom,
"amount" => price($r->total_ttc),
"status" => $statusLabel,
"url" => DOL_URL_ROOT . "/compta/facture/card.php?id=" . $r->rowid
];
}
$this->db->free($resql);
}
if (empty($list)) {
return ["info" => "No " . $status . " invoices found matching your criteria."];
}
return $list;
}
/**
* Get full invoice details.
*
* @param array<string, mixed> $args Input parameters (ref or id)
*
* @return array<string, mixed>
*/
private function getInvoice($args)
{
$id = isset($args['ref']) ? $args['ref'] : (isset($args['id']) ? $args['id'] : null);
$invoice = $this->findInvoice($id);
if (is_array($invoice)) {
return $invoice;
}
$invoice->fetch_thirdparty();
$invoice->fetch_lines();
$lines = [];
foreach ($invoice->lines as $l) {
$prodRef = !empty($l->product_ref) ? $l->product_ref : (!empty($l->product_label) ? $l->product_label : '');
$lines[] = [
"product" => $prodRef,
"desc" => dol_html_entity_decode(strip_tags($l->desc), ENT_QUOTES),
"qty" => (float) $l->qty,
"price" => price($l->subprice),
"total_line" => price($l->total_ht),
"vat" => $l->tva_tx . "%"
];
}
return [
"id" => $invoice->id,
"ref" => $invoice->ref,
"date" => dol_print_date($invoice->date, 'day'),
"status" => $invoice->getLibStatut(1),
"customer" => $invoice->thirdparty->name,
"total_ht" => price($invoice->total_ht),
"total_ttc" => price($invoice->total_ttc),
"lines" => $lines,
"url" => DOL_URL_ROOT . "/compta/facture/card.php?id=" . $invoice->id
];
}
/**
* Validate a draft invoice.
*
* @param array<string, mixed> $args Input parameters (invoice ref or id)
*
* @return array<string, mixed>
*/
private function validateInvoice($args)
{
$user = $this->getUser();
if ($user === null) {
return ["error" => "User not authenticated."];
}
$invoice = $this->findInvoice($args['invoice']);
if (is_array($invoice)) {
return $invoice;
}
if ($invoice->statut != 0) {
return ["error" => "Invoice is already validated."];
}
if ($invoice->validate($user) < 0) {
$error = $invoice->error;
if (!empty($invoice->errors)) {
$error .= ' ' . implode(', ', $invoice->errors);
}
if (empty(trim($error))) {
$error = 'Unknown error (validate returned < 0 with no message)';
}
return ["error" => "Validation failed: " . $error];
}
$invoice->fetch($invoice->id);
return [
"success" => true,
"new_ref" => $invoice->ref,
"status" => "Validated (Unpaid)",
"url" => DOL_URL_ROOT . "/compta/facture/card.php?id=" . $invoice->id
];
}
/**
* Register a payment on an invoice.
*
* @param array<string, mixed> $args Input parameters (invoice, amount, payment_mode, bank_account)
*
* @return array<string, mixed>
*/
private function payInvoice($args)
{
$user = $this->getUser();
if ($user === null) {
return ["error" => "User not authenticated."];
}
$invoice = $this->findInvoice($args['invoice']);
if (is_array($invoice)) {
return $invoice;
}
// Cannot pay drafts
if ($invoice->statut == 0) {
return ["error" => "Cannot pay a Draft invoice. Please validate it first."];
}
$bank = $this->findBankAccount(isset($args['bank_account']) ? $args['bank_account'] : '');
if (!$bank) {
return ["error" => "No active Bank account found to receive payment."];
}
$remaining = $invoice->total_ttc - $invoice->getSommePaiement();
if ($remaining <= 0) {
return ["error" => "Invoice is already fully paid."];
}
$amount = isset($args['amount']) ? (float) $args['amount'] : $remaining;
if ($amount > $remaining) {
$amount = $remaining;
}
$code = isset($args['payment_mode']) ? $args['payment_mode'] : 'VIR';
$modeId = dol_getIdFromCode($this->db, $code, 'c_paiement', 'code', 'id');
$this->db->begin();
$payment = new Paiement($this->db);
$payment->datepaye = dol_now();
$payment->amounts = [$invoice->id => $amount];
$payment->paiementid = $modeId;
$payment->paiementcode = $code;
$paymentId = $payment->create($user, 1);
if ($paymentId < 0) {
$this->db->rollback();
return ["error" => "Payment creation failed: " . implode(', ', $payment->errors)];
}
$payment->fetch($paymentId);
if ($payment->addPaymentToBank($user, 'payment', '(Payment via AI)', $bank->rowid, '', '') < 0) {
$this->db->rollback();
return ["error" => "Failed to add payment to bank ledger."];
}
$this->db->commit();
return [
"success" => true,
"paid_amount" => price($amount),
"remaining_due" => price($remaining - $amount),
"status" => ($remaining - $amount <= 0) ? "Fully Paid" : "Partially Paid",
"payment_url" => DOL_URL_ROOT . "/compta/paiement/card.php?id=" . $paymentId
];
}
/**
* Find a customer by identifier.
*
* @param string $identifier ID, ref, code or name
* @return Societe|array<string, mixed>
*/
private function findCustomer($identifier)
{
global $conf;
$customer = new Societe($this->db);
$identifier = trim($identifier);
if (preg_match('/^(?:socid|id)[:\s]+(\d+)$/i', $identifier, $m)) {
$identifier = $m[1];
} elseif (preg_match('/^(?:code|ref)[:\s]+(.+)$/i', $identifier, $m)) {
$identifier = $m[1];
}
if (is_numeric($identifier)) {
if ($customer->fetch((int) $identifier) > 0) {
return $customer;
}
}
// Exact
$sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "societe
WHERE (nom = '" . $this->db->escape($identifier) . "'
OR code_client = '" . $this->db->escape($identifier) . "')
AND entity IN (" . getEntity('societe') . ")";
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
$obj = $this->db->fetch_object($resql);
$customer->fetch($obj->rowid);
$this->db->free($resql);
return $customer;
}
$sql = "SELECT rowid, nom FROM " . MAIN_DB_PREFIX . "societe
WHERE (nom LIKE '%" . $this->db->escape($identifier) . "%'
OR code_client LIKE '%" . $this->db->escape($identifier) . "%')
AND entity IN (" . getEntity('societe') . ")
LIMIT 5";
$resql = $this->db->query($sql);
if ($resql) {
$num = $this->db->num_rows($resql);
if ($num == 1) {
$obj = $this->db->fetch_object($resql);
$customer->fetch($obj->rowid);
$this->db->free($resql);
return $customer;
} elseif ($num > 1) {
$matches = [];
while ($obj = $this->db->fetch_object($resql)) {
$matches[] = $obj->nom;
}
$this->db->free($resql);
return ["error" => "Multiple customers found.", "matches" => $matches];
}
}
return ["error" => "Customer not found."];
}
/**
* Find an invoice by identifier.
*
* @param string|int $identifier Invoice ID or ref
* @return Facture|array<string, string>
*/
private function findInvoice($identifier)
{
$invoice = new Facture($this->db);
$identifier = trim($identifier);
if (preg_match('/^\(?prov[-_]?(\d+)\)?$/i', $identifier, $matches)) {
if ($invoice->fetch((int) $matches[1]) > 0) {
return $invoice;
}
}
if (is_numeric($identifier)) {
if ($invoice->fetch((int) $identifier) > 0) {
return $invoice;
}
}
if ($invoice->fetch(0, $identifier) > 0) {
return $invoice;
}
return ["error" => "Invoice not found."];
}
/**
* Find a bank account.
*
* @param string|int $identifier Bank account id, ref or label
* @return object|null
*/
private function findBankAccount($identifier)
{
global $conf;
$identifier = trim($identifier);
$params = [];
$sql = "SELECT rowid, label FROM " . MAIN_DB_PREFIX . "bank_account
WHERE entity IN (" . getEntity('bank_account') . ") AND clos = 0";
if (is_numeric($identifier)) {
$sql .= " AND rowid = " . ((int) $identifier);
} elseif (!empty($identifier)) {
$sql .= " AND (ref = '" . $this->db->escape($identifier) . "'
OR label LIKE '%" . $this->db->escape($identifier) . "%')";
}
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
$obj = $this->db->fetch_object($resql);
$this->db->free($resql);
return $obj;
}
// fallback
$sql = "SELECT rowid, label FROM " . MAIN_DB_PREFIX . "bank_account
WHERE entity IN (" . getEntity('bank_account') . ") AND clos = 0
LIMIT 1";
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
$obj = $this->db->fetch_object($resql);
$this->db->free($resql);
return $obj;
}
return null;
}
}