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
588 lines
16 KiB
PHP
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;
|
|
}
|
|
}
|