dolibarr 25.0.0-alpha
invoices.class.php
1<?php
2/* Copyright (C) 2026 Laurent Destailleur <eldy@users.sourceforge.net>
3 * Copyright (C) 2026 Nick Fragoulis
4 * Copyright (C) 2026 Jose Martinez <jose.martinez@pichinov.com>
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY, without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18 */
19
26require_once DOL_DOCUMENT_ROOT . '/compta/facture/class/facture.class.php';
27require_once DOL_DOCUMENT_ROOT . '/societe/class/societe.class.php';
28require_once DOL_DOCUMENT_ROOT . '/product/class/product.class.php';
29require_once DOL_DOCUMENT_ROOT . '/compta/paiement/class/paiement.class.php';
30require_once DOL_DOCUMENT_ROOT . '/compta/bank/class/account.class.php';
31require_once DOL_DOCUMENT_ROOT . '/core/lib/date.lib.php';
32
38class ToolInvoices extends McpTool
39{
40
50 public function __construct(DoliDB $db, $user = null, $conf = null)
51 {
52 $this->db = $db;
53 $this->user = $user;
54 if ($conf !== null) {
55 $this->conf = $conf;
56 }
57 }
58
65 private function getUser()
66 {
67 // is_object() instead of empty() to avoid PHPStan flagging empty() on a
68 // non-nullable typed parent property as unreachable.
69 if (is_object($this->user) && !empty($this->user->id)) {
70 return $this->user;
71 }
72 global $user;
73 return is_object($user) && !empty($user->id) ? $user : null;
74 }
75
81 public function getDefinitions(): array
82 {
83 return [
84 [
85 "name" => "search_invoice",
86 "description" => "Search for invoices. By default, lists UNPAID invoices. Excludes drafts.",
87 "inputSchema" => [
88 "type" => "object",
89 "properties" => [
90 "customer" => [
91 "type" => "string",
92 "description" => "Optional: Customer name."
93 ],
94 "status" => [
95 "type" => "string",
96 "enum" => ["unpaid", "paid", "draft", "all"],
97 "description" => "Filter by status. Default is 'unpaid'. 'all' shows history but excludes drafts.",
98 "default" => "unpaid"
99 ],
100 "limit" => [
101 "type" => "integer",
102 "default" => 10
103 ]
104 ]
105 ]
106 ],
107 [
108 "name" => "get_invoice",
109 "description" => "Get details of a specific invoice by ID or Reference.",
110 "inputSchema" => [
111 "type" => "object",
112 "properties" => [
113 "ref" => ["type" => "string", "description" => "Invoice Ref (e.g. FA2401-001)"],
114 "id" => ["type" => "integer", "description" => "Invoice ID"]
115 ],
116 "oneOf" => [
117 ["required" => ["ref"]],
118 ["required" => ["id"]]
119 ]
120 ]
121 ],
122 [
123 "name" => "validate_invoice",
124 "description" => "Validate a draft invoice.",
125 "inputSchema" => [
126 "type" => "object",
127 "properties" => [
128 "invoice" => ["type" => "string", "description" => "Invoice ID or Ref."]
129 ],
130 "required" => ["invoice"]
131 ]
132 ],
133 [
134 "name" => "pay_invoice",
135 "description" => "Register a payment for an invoice.",
136 "inputSchema" => [
137 "type" => "object",
138 "properties" => [
139 "invoice" => ["type" => "string", "description" => "Invoice ID or Reference."],
140 "amount" => ["type" => "number", "description" => "Amount to pay. Defaults to full remaining."],
141 "payment_mode" => ["type" => "string", "description" => "Code (VIR, CB, LIQ)."],
142 "bank_account" => ["type" => "string", "description" => "Bank Account Name/Ref."]
143 ],
144 "required" => ["invoice"]
145 ]
146 ]
147 ];
148 }
149
156 public function getCategories(): array
157 {
158 return ['billing'];
159 }
160
168 public function execute(string $name, array $args)
169 {
170 switch ($name) {
171 case 'search_invoice':
172 case 'search_invoices':
173 return $this->searchInvoices($args);
174
175 case 'get_invoice':
176 return $this->getInvoice($args);
177
178 case 'validate_invoice':
179 return $this->validateInvoice($args);
180
181 case 'pay_invoice':
182 return $this->payInvoice($args);
183
184 default:
185 return ["error" => "Tool function '$name' not found."];
186 }
187 }
188
196 private function searchInvoices($args)
197 {
198 $limit = isset($args['limit']) ? (int) $args['limit'] : 10;
199 $status = isset($args['status']) ? $args['status'] : 'unpaid';
200
201 // Safety fallback
202 if ($limit <= 0) {
203 $limit = 5;
204 }
205 if ($limit > 1000) {
206 dol_syslog("Search DB Error: Too many record requested", LOG_ERR);
207 return ["error" => "DB Error"];
208 }
209
210 $sql = "SELECT f.rowid, f.ref, f.total_ttc, f.fk_statut, f.paye, f.datef, s.nom
211 FROM " . MAIN_DB_PREFIX . "facture as f
212 LEFT JOIN " . MAIN_DB_PREFIX . "societe as s ON f.fk_soc = s.rowid
213 WHERE f.entity IN (" . getEntity('facture') . ")";
214
215 // Status filtering
216 if ($status === 'draft') {
217 // Explicitly asking for drafts
218 $sql .= " AND f.fk_statut = 0";
219 } elseif ($status === 'paid') {
220 // Fully paid
221 $sql .= " AND f.fk_statut = 2";
222 } elseif ($status === 'all') {
223 // Valid invoices (Unpaid + Paid). EXCLUDES Drafts (0) and Abandoned (3)
224 $sql .= " AND f.fk_statut IN (1, 2)";
225 } else {
226 // Default: 'unpaid'
227 // In Dolibarr: fk_statut=1 means Validated but not fully paid.
228 $sql .= " AND f.fk_statut = 1 AND f.paye = 0";
229 }
230
231 // Customer Filter
232 if (!empty($args['customer'])) {
233 $cust = $this->findCustomer($args['customer']);
234 if (!is_array($cust)) {
235 $sql .= " AND f.fk_soc = " . ((int) $cust->id);
236 } else {
237 $sql .= " AND s.nom LIKE '%" . $this->db->escape($args['customer']) . "%'";
238 }
239 }
240
241 $sql .= " ORDER BY f.datef DESC LIMIT " . ((int) $limit);
242
243 $resql = $this->db->query($sql);
244 $list = [];
245
246 if ($resql) {
247 while ($r = $this->db->fetch_object($resql)) {
248 // Double check to ensure no PROV/Drafts slip through unless asked
249 if ($status !== 'draft' && $r->fk_statut == 0) {
250 continue;
251 }
252
253 $ref = ($r->fk_statut == 0 ? "(PROV" . $r->rowid . ")" : $r->ref);
254
255 // Calculate Status Label
256 $statusLabel = "Unknown";
257 if ($r->fk_statut == 0) {
258 $statusLabel = "Draft";
259 } elseif ($r->fk_statut == 1) {
260 $statusLabel = "Unpaid";
261 } elseif ($r->fk_statut == 2) {
262 $statusLabel = "Paid";
263 } elseif ($r->fk_statut == 3) {
264 $statusLabel = "Abandoned";
265 }
266
267 $list[] = [
268 "ref" => $ref,
269 "date" => dol_print_date($this->db->jdate($r->datef), 'day'),
270 "customer" => $r->nom,
271 "amount" => price($r->total_ttc),
272 "status" => $statusLabel,
273 "url" => DOL_URL_ROOT . "/compta/facture/card.php?id=" . $r->rowid
274 ];
275 }
276 $this->db->free($resql);
277 }
278
279 if (empty($list)) {
280 return ["info" => "No " . $status . " invoices found matching your criteria."];
281 }
282
283 return $list;
284 }
285
293 private function getInvoice($args)
294 {
295 $id = isset($args['ref']) ? $args['ref'] : (isset($args['id']) ? $args['id'] : null);
296
297 $invoice = $this->findInvoice($id);
298 if (is_array($invoice)) {
299 return $invoice;
300 }
301
302 $invoice->fetch_thirdparty();
303 $invoice->fetch_lines();
304
305 $lines = [];
306 foreach ($invoice->lines as $l) {
307 $prodRef = !empty($l->product_ref) ? $l->product_ref : (!empty($l->product_label) ? $l->product_label : '');
308
309 $lines[] = [
310 "product" => $prodRef,
311 "desc" => dol_html_entity_decode(strip_tags($l->desc), ENT_QUOTES),
312 "qty" => (float) $l->qty,
313 "price" => price($l->subprice),
314 "total_line" => price($l->total_ht),
315 "vat" => $l->tva_tx . "%"
316 ];
317 }
318
319 return [
320 "id" => $invoice->id,
321 "ref" => $invoice->ref,
322 "date" => dol_print_date($invoice->date, 'day'),
323 "status" => $invoice->getLibStatut(1),
324 "customer" => $invoice->thirdparty->name,
325 "total_ht" => price($invoice->total_ht),
326 "total_ttc" => price($invoice->total_ttc),
327 "lines" => $lines,
328 "url" => DOL_URL_ROOT . "/compta/facture/card.php?id=" . $invoice->id
329 ];
330 }
331
339 private function validateInvoice($args)
340 {
341 $user = $this->getUser();
342 if ($user === null) {
343 return ["error" => "User not authenticated."];
344 }
345 $invoice = $this->findInvoice($args['invoice']);
346 if (is_array($invoice)) {
347 return $invoice;
348 }
349
350 if ($invoice->statut != 0) {
351 return ["error" => "Invoice is already validated."];
352 }
353
354 if ($invoice->validate($user) < 0) {
355 $error = $invoice->error;
356 if (!empty($invoice->errors)) {
357 $error .= ' ' . implode(', ', $invoice->errors);
358 }
359 if (empty(trim($error))) {
360 $error = 'Unknown error (validate returned < 0 with no message)';
361 }
362 return ["error" => "Validation failed: " . $error];
363 }
364
365 $invoice->fetch($invoice->id);
366 return [
367 "success" => true,
368 "new_ref" => $invoice->ref,
369 "status" => "Validated (Unpaid)",
370 "url" => DOL_URL_ROOT . "/compta/facture/card.php?id=" . $invoice->id
371 ];
372 }
373
381 private function payInvoice($args)
382 {
383 $user = $this->getUser();
384 if ($user === null) {
385 return ["error" => "User not authenticated."];
386 }
387 $invoice = $this->findInvoice($args['invoice']);
388 if (is_array($invoice)) {
389 return $invoice;
390 }
391
392 // Cannot pay drafts
393 if ($invoice->statut == 0) {
394 return ["error" => "Cannot pay a Draft invoice. Please validate it first."];
395 }
396
397 $bank = $this->findBankAccount(isset($args['bank_account']) ? $args['bank_account'] : '');
398 if (!$bank) {
399 return ["error" => "No active Bank account found to receive payment."];
400 }
401
402 $remaining = $invoice->total_ttc - $invoice->getSommePaiement();
403 if ($remaining <= 0) {
404 return ["error" => "Invoice is already fully paid."];
405 }
406
407 $amount = isset($args['amount']) ? (float) $args['amount'] : $remaining;
408 if ($amount > $remaining) {
409 $amount = $remaining;
410 }
411
412 $code = isset($args['payment_mode']) ? $args['payment_mode'] : 'VIR';
413 $modeId = dol_getIdFromCode($this->db, $code, 'c_paiement', 'code', 'id');
414
415 $this->db->begin();
416 $payment = new Paiement($this->db);
417 $payment->datepaye = dol_now();
418 $payment->amounts = [$invoice->id => $amount];
419 $payment->paiementid = $modeId;
420 $payment->paiementcode = $code;
421
422 $paymentId = $payment->create($user, 1);
423 if ($paymentId < 0) {
424 $this->db->rollback();
425 return ["error" => "Payment creation failed: " . implode(', ', $payment->errors)];
426 }
427 $payment->fetch($paymentId);
428 if ($payment->addPaymentToBank($user, 'payment', '(Payment via AI)', $bank->rowid, '', '') < 0) {
429 $this->db->rollback();
430 return ["error" => "Failed to add payment to bank ledger."];
431 }
432
433 $this->db->commit();
434
435 return [
436 "success" => true,
437 "paid_amount" => price($amount),
438 "remaining_due" => price($remaining - $amount),
439 "status" => ($remaining - $amount <= 0) ? "Fully Paid" : "Partially Paid",
440 "payment_url" => DOL_URL_ROOT . "/compta/paiement/card.php?id=" . $paymentId
441 ];
442 }
443
450 private function findCustomer($identifier)
451 {
452 global $conf;
453
454 $customer = new Societe($this->db);
455 $identifier = trim($identifier);
456
457 if (preg_match('/^(?:socid|id)[:\s]+(\d+)$/i', $identifier, $m)) {
458 $identifier = $m[1];
459 } elseif (preg_match('/^(?:code|ref)[:\s]+(.+)$/i', $identifier, $m)) {
460 $identifier = $m[1];
461 }
462
463 if (is_numeric($identifier)) {
464 if ($customer->fetch((int) $identifier) > 0) {
465 return $customer;
466 }
467 }
468
469 // Exact
470 $sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "societe
471 WHERE (nom = '" . $this->db->escape($identifier) . "'
472 OR code_client = '" . $this->db->escape($identifier) . "')
473 AND entity IN (" . getEntity('societe') . ")";
474
475 $resql = $this->db->query($sql);
476
477 if ($resql && $this->db->num_rows($resql) > 0) {
478 $obj = $this->db->fetch_object($resql);
479 $customer->fetch($obj->rowid);
480 $this->db->free($resql);
481 return $customer;
482 }
483
484 $sql = "SELECT rowid, nom FROM " . MAIN_DB_PREFIX . "societe
485 WHERE (nom LIKE '%" . $this->db->escape($identifier) . "%'
486 OR code_client LIKE '%" . $this->db->escape($identifier) . "%')
487 AND entity IN (" . getEntity('societe') . ")
488 LIMIT 5";
489
490 $resql = $this->db->query($sql);
491
492 if ($resql) {
493 $num = $this->db->num_rows($resql);
494
495 if ($num == 1) {
496 $obj = $this->db->fetch_object($resql);
497 $customer->fetch($obj->rowid);
498 $this->db->free($resql);
499 return $customer;
500 } elseif ($num > 1) {
501 $matches = [];
502 while ($obj = $this->db->fetch_object($resql)) {
503 $matches[] = $obj->nom;
504 }
505 $this->db->free($resql);
506 return ["error" => "Multiple customers found.", "matches" => $matches];
507 }
508 }
509
510 return ["error" => "Customer not found."];
511 }
512
519 private function findInvoice($identifier)
520 {
521 $invoice = new Facture($this->db);
522 $identifier = trim($identifier);
523
524 if (preg_match('/^\‍(?prov[-_]?(\d+)\‍)?$/i', $identifier, $matches)) {
525 if ($invoice->fetch((int) $matches[1]) > 0) {
526 return $invoice;
527 }
528 }
529 if (is_numeric($identifier)) {
530 if ($invoice->fetch((int) $identifier) > 0) {
531 return $invoice;
532 }
533 }
534 if ($invoice->fetch(0, $identifier) > 0) {
535 return $invoice;
536 }
537
538 return ["error" => "Invoice not found."];
539 }
540
547 private function findBankAccount($identifier)
548 {
549 global $conf;
550
551 $identifier = trim($identifier);
552 $params = [];
553
554
555 $sql = "SELECT rowid, label FROM " . MAIN_DB_PREFIX . "bank_account
556 WHERE entity IN (" . getEntity('bank_account') . ") AND clos = 0";
557
558 if (is_numeric($identifier)) {
559 $sql .= " AND rowid = " . ((int) $identifier);
560 } elseif (!empty($identifier)) {
561 $sql .= " AND (ref = '" . $this->db->escape($identifier) . "'
562 OR label LIKE '%" . $this->db->escape($identifier) . "%')";
563 }
564
565 $resql = $this->db->query($sql);
566
567 if ($resql && $this->db->num_rows($resql) > 0) {
568 $obj = $this->db->fetch_object($resql);
569 $this->db->free($resql);
570 return $obj;
571 }
572
573 // fallback
574 $sql = "SELECT rowid, label FROM " . MAIN_DB_PREFIX . "bank_account
575 WHERE entity IN (" . getEntity('bank_account') . ") AND clos = 0
576 LIMIT 1";
577
578 $resql = $this->db->query($sql);
579
580 if ($resql && $this->db->num_rows($resql) > 0) {
581 $obj = $this->db->fetch_object($resql);
582 $this->db->free($resql);
583 return $obj;
584 }
585
586 return null;
587 }
588}
$id
Support class for third parties, contacts, members, users or resources.
Definition account.php:47
Class to manage Dolibarr database access.
Class to manage invoices.
Abstract base class for all MCP (Model Context Protocol) tools.
Class to manage payments of customer invoices.
Class to manage third parties objects (customers, suppliers, prospects...)
Class ToolInvoices.
searchInvoices($args)
Search invoices based on filters.
findInvoice($identifier)
Find an invoice by identifier.
getInvoice($args)
Get full invoice details.
payInvoice($args)
Register a payment on an invoice.
getUser()
Return the authenticated user, preferring DI ($this->user) and falling back to global $user.
findCustomer($identifier)
Find a customer by identifier.
execute(string $name, array $args)
Executes the requested tool function based on its name.
findBankAccount($identifier)
Find a bank account.
getCategories()
Return categories this tool belongs to.
__construct(DoliDB $db, $user=null, $conf=null)
Constructor.
getDefinitions()
Returns an array of tool definitions, including name, description, and input schema.
validateInvoice($args)
Validate a draft invoice.
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $db
API class for accounts.
dol_html_entity_decode($a, $b, $c='UTF-8', $keepsomeentities=0)
Replace html_entity_decode functions to manage errors.
dol_now($mode='gmt')
Return date for now.
dol_getIdFromCode($db, $key, $tablename, $fieldkey='code', $fieldid='id', $entityfilter=0, $filters='', $useCache=true)
Return an id or code from a code or id.
price($amount, $form=0, $outlangs='', $trunc=1, $rounding=-1, $forcerounding=-1, $currency_code='')
Function to format a value into an amount for visual output Function used into PDF and HTML pages.
dol_print_date($time, $format='', $tzoutput='auto', $outputlangs=null, $encodetooutput=false, $decorate=0)
Output date in a string format according to outputlangs (or langs if not defined).
dol_syslog($message, $level=LOG_INFO, $ident=0, $suffixinfilename='', $restricttologhandler='', $logcontext=null)
Write log message into outputs.
getEntity($element, $shared=1, $currentobject=null)
Get list of entity id to use.
conf($dolibarr_main_document_root, $realpathconf=null)
Load conf file (file must exists)
Definition inc.php:429
$conf db user
Active Directory does not allow anonymous connections.
Definition repair.php:134