Extensions (plugins)
Overview
The Iberis extension system allows any developer to create add-on modules to enhance the platform's capabilities. Extensions are entirely declarative: a JSON manifest file defines data models, pages, menus, hooks, and widgets.
Extensions are submitted for review by the Iberis team before being published on the marketplace. Once approved, companies can install them in one click from the Integrations page.
Extensions feature a complete system: data models with automatic CRUD, custom fields on existing Iberis entities, PDF generation, approval workflows, and guards to block certain actions under conditions.
Ce que vous pouvez construire
Voici des exemples concrets de plugins réalisables avec le système :
expenses : c'est le nom interne des factures fournisseur dans Iberis, à ne pas confondre avec invoices qui sont les factures clients) sur plusieurs projets (projects), ventilation par quantité ou montant, suivi des marges par projetManifest structure
The JSON manifest defines your entire plugin. Basic information (title, description, icon) is entered via the submission form. The technical manifest contains the following sections:
Exemple complet minimal
{
"models": {
"task": {
"numbering": { "prefix": "TSK", "pattern": "{prefix}-{YYYY}-{sequence}" },
"fields": {
"title": { "type": "string", "required": true, "label": { "fr": "Titre", "en": "Title" } },
"description": { "type": "textarea", "label": { "fr": "Description" } },
"status": { "type": "integer", "default": 0 },
"amount": { "type": "decimal", "label": { "fr": "Montant", "en": "Amount" } },
"due_date": { "type": "date", "label": { "fr": "Échéance", "en": "Due date" } },
"client_id": { "type": "reference", "references": "contacts", "label": { "fr": "Client" } },
"attachment": { "type": "file", "label": { "fr": "Pièce jointe" } }
},
"statuses": {
"0": { "fr": "Brouillon", "en": "Draft" },
"1": { "fr": "En cours", "en": "In Progress" },
"2": { "fr": "Terminé", "en": "Done" },
"3": { "fr": "Annulé", "en": "Cancelled" }
}
}
},
"pages": {
"task_list": {
"type": "list",
"model": "task",
"columns": ["_number", "title", "client_id", "amount", "status", "due_date"]
},
"task_form": {
"type": "form",
"model": "task",
"layout": [
{ "row": [{ "field": "title", "col": 8 }, { "field": "status", "col": 4 }] },
{ "row": [{ "field": "client_id", "col": 6 }, { "field": "amount", "col": 6 }] },
{ "row": [{ "field": "due_date", "col": 6 }, { "field": "attachment", "col": 6 }] },
{ "row": [{ "field": "description", "col": 12 }] }
]
}
},
"menus": [
{
"section": {
"title": { "fr": "Gestion de tâches", "en": "Task Management" },
"icon": "fas fa-tasks",
"items": [
{ "label": { "fr": "Taches", "en": "Tasks" }, "page": "task_list" }
]
}
}
],
"widgets": {
"total_tasks": {
"type": "stats_card",
"model": "task",
"aggregate": "COUNT",
"label": { "fr": "Total tâches", "en": "Total tasks" },
"icon": "fas fa-tasks",
"color": "#667eea"
},
"total_amount": {
"type": "stats_card",
"model": "task",
"aggregate": "SUM",
"field": "amount",
"filter": { "status": 2 },
"label": { "fr": "Montant termine", "en": "Completed amount" },
"icon": "fas fa-coins",
"color": "#10b981"
},
"by_status": {
"type": "chart",
"chart_type": "donut",
"model": "task",
"group_by": "status",
"aggregate": "COUNT",
"label": { "fr": "Par statut", "en": "By status" },
"color": "#667eea"
}
},
"hooks": {},
"settings": {},
"extensions": {},
"guards": {}
}Pages
list, form et dashboard. list et form exigent un model ; dashboard n'en prend pas (il affiche ses propres widgets). Un type inconnu donne une page blanche, sans message d'erreur. Si vous déclarez un
layout, déclarez-y tous les champs modifiables. Un champ absent du layout n'est jamais affiché ni enregistré — il devient inaccessible à l'utilisateur, même s'il est obligatoire ou utilisé dans un PDF. C'est l'erreur la plus fréquente. Sans layout, le moteur place tous les champs automatiquement. col est une colonne de grille Bootstrap sur 12 : chaque row doit totaliser 12. Une valeur hors 1–12 casse la largeur de la cellule. Colonnes spéciales des listes :
_number (le numéro, cliquable vers le formulaire) et _status (badge de statut). Une colonne nommée status devient aussi un badge. Limites à connaître
Noms de models —
^[a-z][a-z0-9_]*$. Réservés : user, users, company, companies, plugin, plugins, admin, system. Noms de champs —
^[a-z][a-z0-9_]*$. Réservés (ajoutés automatiquement) : id, company_id, created_by, created_at, updated_at, deleted_at, parent_id, number. plugin.id — ^[a-z][a-z0-9\-]*$, et plg_{plugin.id}_{model} doit tenir en 64 caractères (limite MySQL). Au-delà, la soumission est refusée. Noms de permissions —
^[A-Za-z][A-Za-z0-9]*$ : ni tiret ni underscore. plugin.category — une valeur parmi finance, ecommerce, collaboration, backup, legal, security. Garde-fous des hooks : profondeur maximale 5, 10 exécutions par hook et 50 au total par requête (un hook écarté par ses
conditions ne consomme rien). Au-delà, les hooks s'arrêtent avec une ligne dans les logs. Fichiers : 10 Mo maximum, et les fichiers déposés sont accessibles par URL publique — n'y stockez rien de confidentiel.
Mise à jour d'un plugin : seules les nouvelles colonnes sont ajoutées, et toujours en nullable. Changer un type, une longueur ou supprimer un champ n'a aucun effet sur les tables existantes.
Field types
Chaque model définit ses champs avec un type. Le moteur crée automatiquement les colonnes en base et le formulaire adapté.
Tous les types
"fields": {
"name": { "type": "string", "required": true, "max": 100, "label": { "fr": "Nom" } },
"notes": { "type": "textarea", "label": { "fr": "Notes" } },
"quantity": { "type": "integer", "default": 1 },
"price": { "type": "decimal", "precision": 15, "scale": 3 },
"is_active": { "type": "boolean", "default": true },
"start_date": { "type": "date" },
"created_at": { "type": "datetime" },
"document": { "type": "file", "label": { "fr": "Document" } },
"photo": { "type": "image", "label": { "fr": "Photo" } },
"category": { "type": "string", "max": 40, "label": { "fr": "Catégorie (A, B, C…)" } },
"client_id": {
"type": "reference",
"references": "contacts",
"label": { "fr": "Client" }
},
"related_task_id": {
"type": "reference",
"references": "@task",
"label": { "fr": "Tâche liée" }
},
"total_computed": {
"type": "computed",
"formula": "quantity * price",
"label": { "fr": "Total" }
}
}select, mais ajouter un tableau options à n'importe quel champ le fait rendre comme une liste déroulante dans le formulaire : "categorie": { "type": "string", "options": [{ "value": "a", "label": { "fr": "Option A" } }] }. Le champ réservé status se comporte aussi comme un select (voir Statuses ci-dessous). Trois limites à anticiper : rien ne valide côté serveur qu'une valeur saisie ou importée appartient à la liste (une faute de frappe passe silencieusement, y compris dans une condition de hook) ; les listes, onglets et dashboards affichent la valeur brute, pas le
label de l'option ; et options est ignoré dans les lignes enfant d'un formulaire maître-détail. hint affiche une note en gris sous le champ dans le formulaire : "entrepot_id": { "type": "reference", "references": "warehouses", "label": { "fr": "Entrepôt" }, "hint": { "fr": "Vide = entrepôt par défaut" } }. Elle accepte les mêmes langues que label. Mettez-y les consignes de saisie plutôt que dans le label : le libellé est réutilisé partout où le champ est nommé — en-tête de colonne d'une liste, fiche Détails, PDF — où une consigne n'a pas de sens. locked_when rend un champ non modifiable dès que l'enregistrement atteint l'état indiqué. "article_id": {
"type": "reference",
"references": "items",
"locked_when": { "field": "status", "equals": 1 }
} Deux clés, toutes deux obligatoires : field (un champ de ce même model) et equals (la valeur qui déclenche le gel). Le formulaire affiche le champ désactivé, et le serveur ignore la valeur si elle est tout de même envoyée — le verrou tient donc aussi face à un appel direct à l'API. L'état lu est celui enregistré, pas celui affiché à l'écran. Une même requête qui change
status et un champ gelé n'en applique que le status : c'est précisément la modification que le verrou empêche. Repassez d'abord le document dans l'état ouvert, enregistrez, puis modifiez le champ. À quoi cela sert : quand un hook a déjà écrit quelque part d'après un champ — une sortie de stock faite sur un article et un entrepôt donnés — laisser ce champ modifiable après coup fait diverger l'écriture déjà passée de ce que dit le document, sans aucune alerte. Gelez les champs qui identifient l'écriture (article, entrepôt, tiers) ; laissez modifiables ceux qui en portent le montant, qu'un hook sur
@model.updated peut rattraper. Le verrou vaut pour toute écriture, y compris celle d'un de vos propres hooks : une action
update_record qui vise un champ gelé est ignorée elle aussi. Il fonctionne aussi sur un model enfant, où il gèle la cellule correspondante du tableau des lignes : la condition porte alors sur un champ de la ligne elle-même, pas du parent.
Deux limites : rien n'est gelé à la création — il n'y a pas encore d'état antérieur, et une ligne ajoutée dans la session en cours reste donc modifiable jusqu'à son enregistrement ; et
locked_when est refusé à la soumission sur un champ computed, qui n'est de toute façon jamais enregistré. @ pour référencer un modèle interne au plugin (ex: @task). Voici les 20 entités système disponibles : Documents :
invoices, expenses, estimates, credits, deliveries, orders, receipts, services, disbursements, exit_vouchersContacts & articles :
contacts, itemsFinance :
payments, withholdings, journals, journal_entries, company_banks, cash_accountsStock & projets :
warehouses, projectsCette même liste s'applique aux champs
reference, à LOOKUP(), à bulk_create_from_related.source_model et à la source d'une colonne {dynamic:…} — il n'y a qu'un seul référentiel. Les lignes de documents ne sont pas référençables.
invoice_items, expense_items et les sept autres *_items ne font pas partie de la liste : ces tables ne portent pas de company_id, donc aucune requête ne peut les isoler par entreprise. Un manifeste qui les cite est refusé à la soumission. Une cible inconnue dans
LOOKUP() retourne silencieusement null (aucune erreur) : vérifiez l'orthographe. Numérotation
Déclarée via numbering sur un model (voir l'exemple task plus haut). Deux clés : prefix (défaut "PLG") et pattern.
{prefix}, {YYYY} et {sequence} — ce dernier est complété à 5 chiffres. Tout autre jeton est laissé tel quel dans le numéro final. Il n'y a pas de {MM}, de {DD} ni de padding configurable. La recherche du dernier numéro se fait à travers votre propre
pattern, donc l'agencement est libre : "{prefix}-{YYYY}-{sequence}", "{prefix}-{sequence}", "{sequence}/{YYYY}" ou "{prefix}{sequence}" s'incrémentent tous correctement. La séquence est remise à zéro chaque année uniquement si {YYYY} figure dans le pattern. Le numéro est unique par entreprise (contrainte en base) : deux enregistrements créés simultanément ne peuvent pas recevoir le même numéro.
Statuses (badges automatiques)
"statuses": {
"0": { "fr": "Brouillon", "en": "Draft" },
"1": { "fr": "En attente", "en": "Pending" },
"2": { "fr": "Approuvé", "en": "Approved" },
"3": { "fr": "Terminé", "en": "Completed" },
"4": { "fr": "Rejeté", "en": "Rejected" }
}Le champ status (integer) devient un select dans le formulaire et un badge coloré dans la liste. Couleurs : 0=secondary, 1=primary, 2=info, 3=success, 4=danger, 5=dark ; au-delà de 5, secondary. Les trois conditions doivent être réunies : le champ doit s'appeler exactement status, être de type integer, et le model doit déclarer statuses.
Formulas and expressions
Utilisées dans les champs computed, les actions de hooks, et les conditions. Le parseur est un AST récursif sécurisé.
Exemples
// Champ computed - total TTC
"formula": "ROUND(quantity * price * (1 + tax_rate / 100), 3)"
// Condition hook - déclencher si montant > 1000
"conditions": [{ "field": "amount", "greater_than": 1000 }]
// Action hook - créer un record avec formule
"fields": {
"title": "CONCAT('Commande #', number)",
"total": "SUM(lines.quantity * lines.price)",
"days_left": "DATE_DIFF(due_date, TODAY())"
}
// Lookup inter-model
"formula": "LOOKUP(@bom, item_id = id, unit_cost)"
// Lire un paramètre du plugin (voir section Paramètres)
"formula": "SETTING('daily_limit')"Fonctions disponibles
1. Avec
@model, n'importe quel model du plugin. Sans @, les 20 entités système listées dans la section Références — c'est le même référentiel que les champs reference. Une entité hors de cette liste retourne silencieusement null. Les enregistrements supprimés (corbeille) sont exclus. 2.
LOOKUP() lit uniquement de vraies colonnes en base, jamais un champ computed d'un autre model — même s'il porte un nom qui ressemble à une colonne. Si vous avez besoin d'une valeur calculée sur le model référencé, chaînez plutôt le calcul via un champ computed sur le model appelant lui-même. 3. L'opérateur de la condition est respecté (
=, !=, >, <, >=, <=). Le nom du model et le champ retourné s'écrivent sans quotes ; la valeur comparée peut être un chemin de champ (sans quotes) ou un littéral (entre quotes). lignes.quantite désigne la colonne sur toutes les lignes, pas une valeur unique. Les opérations se font ligne par ligne, donc SUM(lignes.quantite * lignes.prix) additionne bien le produit de chaque ligne. Enveloppez toujours ce genre d'expression dans un agrégat (
SUM, AVG, MIN, MAX, COUNT) : sans agrégat le résultat reste une liste de valeurs, pas un nombre. Si les deux côtés n'ont pas le même nombre de lignes, le résultat est null. computed : un champ calculé peut lire un autre champ calculé du même enregistrement, mais uniquement s'il est déclaré avant lui dans fields. Un champ qui référence un calcul déclaré plus bas lit null. Si une formule est invalide (fonction inconnue, parenthèse manquante, jeton de trop, plus de 250 jetons), elle retourne
null et l'erreur est écrite dans les logs du serveur avec le texte de la formule. Le champ s'affiche vide : si un calcul reste désespérément vide, c'est là qu'il faut regarder. Hooks and actions
Les hooks réagissent aux événements et exécutent des actions. Exemple : à chaque facture créée, créer automatiquement un suivi dans votre plugin.
Exemple : créer un record à chaque facture
"hooks": {
"on_invoice_created": {
"event": "invoice.created",
"conditions": [
{ "field": "status", "not_equals": 0 }
],
"actions": [
{
"type": "create_record",
"model": "invoice_tracking",
"fields": {
"invoice_id": "id",
"invoice_number": "invoice_number",
"amount": "total",
"status": "0",
"tracked_at": "NOW()"
}
}
]
}
}Opérateurs de condition disponibles
Liste complète — utilisable dans conditions (hooks) et dans les filter de widgets. Une valeur d'opérande peut être une constante ou une formule (ex. "SETTING('seuil')").
equals, not_equalsgreater_than, less_than, greater_or_equal, less_or_equalin, not_in — valeur = tableauchanged_to — vrai quand le champ vient de prendre cette valeur. La comparaison avec l'ancienne valeur ne fonctionne que sur les événements @model.* de votre plugin : sur les événements Iberis (invoice.updated, etc.) l'état précédent n'est pas transmis, donc la condition se comporte comme un simple equals et se déclenche à chaque modification de la factureis_empty, is_not_empty — valeur = truegreater_than_or_equal / less_than_or_equal — ce sont greater_or_equal / less_or_equal. Une clé d'opérateur non reconnue n'est jamais signalée comme une erreur : la condition correspondante est silencieusement ignorée, ce qui peut rendre une condition bien plus permissive que prévu sans qu'aucune alerte n'apparaisse. Exemple : mouvement de stock + ecriture comptable
"hooks": {
"on_order_completed": {
"event": "@order.updated",
"conditions": [
{ "field": "status", "changed_to": 3 }
],
"actions": [
{
"type": "stock_movement",
"item": "item_id",
"quantity_formula": "quantity",
"direction": "out",
"warehouse": "warehouse_id"
},
{
"type": "journal_entry",
"debit": "601",
"credit": "401",
"amount_formula": "total",
"label_formula": "CONCAT('Plugin order #', number)",
"date": "order_date"
},
{
"type": "send_email",
"to_formula": "LOOKUP(contacts, id = client_id, email)",
"subject": "Votre commande est prête",
"body": "Bonjour, votre commande a été traitée."
}
]
}
}journal_entry — date : optionnel, une formule. Omise, la date du jour est utilisée.
Exemple : webhook + appel API externe
"hooks": {
"notify_external": {
"event": "payment.received",
"actions": [
{
"type": "webhook",
"url": "https://mon-erp.com/webhooks/iberis",
"event_name": "payment.received"
},
{
"type": "api_call",
"url": "https://api.mon-service.com/sync",
"method": "POST",
"headers": { "Authorization": "Bearer MY_TOKEN" },
"body": { "reference": "payment_number", "amount": "total" },
"response_mapping": { "external_id": "data.id" },
"target_model": "sync_log"
}
]
}
}Exemple : regrouper des enregistrements liés (bulk_create_from_related)
create_record agit sur l'enregistrement qui a déclenché le hook. bulk_create_from_related fait la même chose pour tout un ensemble : il cherche les lignes de source_model qui correspondent à match, puis crée une ligne de target_model par résultat trouvé.
// Reprend le model order / order_line de la section Master-detail :
// à la création d'un récap mensuel, rattacher les commandes du même
// client tombant dans la période.
"hooks": {
"regenerer_lignes": {
"event": "@monthly_recap.created",
"actions": [
{
"type": "bulk_create_from_related",
"source_model": "@order",
"match": [
{ "field": "client_id", "equals": "client_id" },
{ "field": "date", "greater_or_equal": "period_start" },
{ "field": "date", "less_or_equal": "period_end" }
],
"target_model": "recap_line",
"fields": {
"recap_id": "id",
"order_id": "source.id",
"amount": "source.total"
}
}
]
}
}@model pour un model du plugin, ou une des 20 entités système listées dans la section Références — même référentiel que les champs reference et LOOKUP().match : seulement 8 des 11 opérateurs de condition —
equals, not_equals, greater_than, less_than, greater_or_equal, less_or_equal, in, not_in. Pas de changed_to ni is_empty / is_not_empty ici. Chaque valeur est une formule évaluée sur l'enregistrement déclencheur — elle peut donc référencer ses champs, ou lire un paramètre via SETTING('cle').target_model : nom du model du plugin où créer les lignes (sans
@, comme pour create_record).fields : même langage de formules que
create_record. La ligne source correspondante est en plus exposée sous source (ex. source.total).Pour que les lignes créées apparaissent automatiquement dans le formulaire du parent, déclarez
target_model avec un parent comme en Master-detail. Limité à 500 lignes créées par exécution. Exemple : mettre à jour un autre enregistrement (update_record)
Modifie un enregistrement existant plutôt que d'en créer un — typiquement un record lié à celui qui a déclenché le hook.
"hooks": {
"on_payment_confirmed": {
"event": "@payment.updated",
"conditions": [
{ "field": "status", "changed_to": 1 }
],
"actions": [
{
"type": "update_record",
"model": "invoice_tracking",
"record_id": "LOOKUP(@invoice_tracking, payment_id = id, id)",
"fields": {
"status": "1",
"paid_at": "NOW()"
}
}
]
}
}@).record_id : une formule évaluée sur l'enregistrement déclencheur — peut utiliser
LOOKUP() pour retrouver l'id du record lié. Rien ne se passe si elle s'évalue à vide.fields : même langage de formules que
create_record — seuls les champs listés sont modifiés, le reste de l'enregistrement est inchangé. Événements disponibles
invoice.createdinvoice.updatedinvoice.deletedexpense.createdexpense.updatedexpense.deletedpayment.receivedcontact.createdcontact.updatedcontact.deletedestimate.createdestimate.updatedestimate.deleteditem.createditem.updateditem.deletedservice.createdservice.updatedservice.deletedorder.createdorder.updatedorder.deleteddelivery.createddelivery.updateddelivery.deletedcredit.createdcredit.updatedcredit.deletedreceipt.createdreceipt.updatedreceipt.deletedexit_voucher.createdexit_voucher.updatedexit_voucher.deletedtej.createdtej.updatedtej.deletedPour les événements internes du plugin, préfixez avec @ : @model_name.created, @model_name.updated, @model_name.deleted
Notifications in-app et push
Envoyez des notifications aux utilisateurs depuis vos hooks. Les notifications apparaissent dans la cloche de l'interface, sur l'application mobile (push Firebase), et optionnellement par email.
Exemple : notification à l'approbation
"hooks": {
"notify_on_approval": {
"event": "@payment_request.updated",
"conditions": [
{ "field": "status", "changed_to": 3 }
],
"actions": [
{
"type": "send_notification",
"recipients": "current_user",
"title": { "fr": "Demande approuvée", "en": "Request approved" },
"content_formula": "CONCAT('La demande #', number, ' a été approuvée.')",
"link": {
"route": "pluginPageRecord",
"params": {
"pluginSlug": "mon-plugin",
"pageName": "request_form",
"recordId": "hashed_id"
}
}
}
]
}
}Destinataires
role)Exemple : notifier tous les managers
{
"type": "send_notification",
"recipients": "role",
"role": "Manager",
"title": { "fr": "Nouvelle demande de paiement" },
"content_formula": "CONCAT('Demande #', number, ' - ', total, ' TND')",
"email": true
}send_email. La notification elle-même apparaît bien dans la cloche de l'interface et sur l'application mobile (push Firebase). Shortcodes
Injectez des données plugin dans les PDFs, emails et templates Iberis.
Exemples
// Nombre total de tâches
{{plugin:task-manager:COUNT(task)}}
// Somme des montants des tâches terminées
{{plugin:task-manager:SUM(task.amount)}}
// Champ custom sur une facture (via extensions)
{{plugin:task-manager:project_code}}
// Expression conditionnelle
{{plugin:task-manager:IF(COUNT(task) > 10, "Beaucoup de tâches", "Peu de tâches")}}Entity extensions
Ajoutez des champs custom aux entités Iberis existantes. Les champs s'affichent automatiquement dans les formulaires de création/edition.
Exemple : ajouter des champs sur les factures et dépenses
"extensions": {
"invoice": {
"custom_fields": {
"project_code": {
"type": "string",
"label": { "fr": "Code projet", "en": "Project code" }
},
"is_billable": {
"type": "boolean",
"label": { "fr": "Facturable", "en": "Billable" },
"default": true
},
"priority": {
"type": "select",
"label": { "fr": "Priorite" },
"options": [
{ "value": "low", "label": { "fr": "Basse", "en": "Low" } },
{ "value": "medium", "label": { "fr": "Moyenne", "en": "Medium" } },
{ "value": "high", "label": { "fr": "Haute", "en": "High" } }
]
}
}
},
"expense": {
"custom_fields": {
"department": {
"type": "string",
"label": { "fr": "Departement", "en": "Department" }
},
"approved_amount": {
"type": "decimal",
"label": { "fr": "Montant approuvé", "en": "Approved amount" }
}
}
}
}custom_fields : string, text, integer, decimal, boolean, date, select. Liste différente de celle des champs de model (section Types de champs) : pas de reference, file, image ni computed ici, et le texte long s'appelle text — pas textarea. Entités extensibles
invoiceexpensepaymentcontactitemestimateorderservicecreditreceiptdeliveryDashboard widgets
Les widgets s'affichent en haut de la page liste de votre plugin. Ajoutez "dashboard": true sur un widget pour l'afficher aussi sur le tableau de bord principal de l'entreprise.
Exemple complet
"widgets": {
"total": {
"type": "stats_card",
"model": "task",
"aggregate": "COUNT",
"label": { "fr": "Total", "en": "Total" },
"icon": "fas fa-list",
"color": "#667eea",
"dashboard": true
},
"completed": {
"type": "stats_card",
"model": "task",
"aggregate": "COUNT",
"filter": { "status": 2 },
"label": { "fr": "Terminées", "en": "Completed" },
"icon": "fas fa-check",
"color": "#10b981"
},
"total_amount": {
"type": "stats_card",
"model": "task",
"aggregate": "SUM",
"field": "amount",
"label": { "fr": "Montant total", "en": "Total amount" },
"icon": "fas fa-coins",
"color": "#f59e0b"
},
"by_status": {
"type": "chart",
"chart_type": "donut",
"model": "task",
"group_by": "status",
"aggregate": "COUNT",
"label": { "fr": "Répartition", "en": "Distribution" },
"color": "#667eea"
}
}Types de graphiques supportés : bar, donut, pie. Agrégations disponibles (widgets stats_card et chart) : COUNT, SUM, AVG, MIN, MAX.
Tabs on existing pages
Ajoutez un onglet dans les pages de synthèse des clients, fournisseurs, articles, ou dans la sidebar des documents (factures, dépenses, etc.). L'onglet affiche les records de votre plugin liés à l'entité.
Exemple : onglet "Deals" sur la fiche client
Le foreign_key doit correspondre à un champ de type reference déclaré dans votre model. C'est ce champ qui fait le lien entre votre record plugin et l'entité Iberis.
// 1. Déclarer le model avec un champ reference vers contacts
"models": {
"deal": {
"fields": {
"title": { "type": "string", "required": true, "label": { "fr": "Titre" } },
"amount": { "type": "decimal", "label": { "fr": "Montant" } },
"status": { "type": "integer", "default": 0 },
"client_id": {
"type": "reference",
"references": "contacts",
"label": { "fr": "Client", "en": "Client" }
}
},
"statuses": {
"0": { "fr": "Nouveau", "en": "New" },
"1": { "fr": "En cours", "en": "In progress" },
"2": { "fr": "Gagné", "en": "Won" }
}
}
},
// 2. Déclarer l'onglet qui utilise ce champ comme foreign_key
"tabs": {
"contact": {
"label": { "fr": "Deals liés", "en": "Related deals" },
"model": "deal",
"foreign_key": "client_id",
"columns": ["_number", "title", "amount", "status"]
}
}client_id = l'ID du client courant. Autre exemple : suivi sur les factures
"models": {
"invoice_tracking": {
"fields": {
"invoice_id": { "type": "reference", "references": "invoices" },
"status": { "type": "integer", "default": 0 },
"tracked_at": { "type": "date" },
"notes": { "type": "text" }
}
}
},
"tabs": {
"invoice": {
"label": { "fr": "Suivi", "en": "Tracking" },
"model": "invoice_tracking",
"foreign_key": "invoice_id",
"columns": ["status", "tracked_at", "notes"]
}
}Entités supportées
client_id de type reference vers contacts). L'onglet filtre automatiquement les records par cette clé. tabs en dehors de cette liste est acceptée sans erreur à la soumission, mais ne s'affiche jamais nulle part — aucune page ne sait la rendre. Settings
Chaque entreprise peut configurer votre plugin via la page Paramètres. Les valeurs sont accessibles dans les hooks et formules.
Exemple
"settings": {
"api_key": {
"type": "string",
"label": { "fr": "Clé API", "en": "API Key" },
"help": { "fr": "Votre clé d'authentification externe", "en": "Your external auth key" },
"default": ""
},
"mode": {
"type": "select",
"label": { "fr": "Mode" },
"default": "production",
"options": [
{ "value": "sandbox", "label": { "fr": "Test", "en": "Sandbox" } },
{ "value": "production", "label": { "fr": "Production" } }
]
},
"auto_sync": {
"type": "boolean",
"label": { "fr": "Synchronisation automatique", "en": "Auto sync" },
"default": true
},
"daily_limit": {
"type": "number",
"label": { "fr": "Limite journalière", "en": "Daily limit" },
"default": 100
},
"notes": {
"type": "textarea",
"label": { "fr": "Notes de configuration", "en": "Config notes" },
"default": ""
}
}Lire un paramètre dans une formule ou une condition
Utilisez SETTING('cle') — la clé entre quotes, comme n'importe quel autre littéral texte du langage de formules. Tant que l'entreprise n'a pas explicitement enregistré de valeur, le default déclaré ci-dessus est utilisé.
// Champ computed
"formula": "IF(quantity > SETTING('daily_limit'), 'Dépassement', 'OK')"
// Condition de hook — le seuil vient du paramètre, pas d'une valeur figée
"conditions": [{ "field": "amount", "greater_than": "SETTING('daily_limit')" }]Actions on documents
Ajoutez des boutons dans le menu déroulant "Action" des documents Iberis (factures, dépenses, devis, etc.) et dans la page formulaire. Trois types d'actions sont disponibles :
Exemple : bouton "Créer une demande de paiement" sur les dépenses
// 1. Le model avec une référence vers expenses
"models": {
"payment_request": {
"fields": {
"title": { "type": "string", "required": true },
"expense_id": { "type": "reference", "references": "expenses" },
"amount": { "type": "decimal" },
"status": { "type": "integer", "default": 0 }
}
}
},
// 2. La page formulaire
"pages": {
"request_form": { "type": "form", "model": "payment_request" }
},
// 3. L'action qui apparaît dans le dropdown de chaque dépense
"actions": {
"create_payment_request": {
"entity_type": "expense",
"label": { "fr": "Créer demande de paiement", "en": "Create payment request" },
"icon": "fas fa-file-invoice",
"color": "primary",
"type": "create_record",
"target_page": "request_form",
"prefill": {
"expense_id": "_id",
"amount": "total"
}
}
}"_id" est une valeur spéciale qui injecte l'identifiant du document. Les autres valeurs correspondent aux champs du document (ex: "total", "invoice_number"). Exemple : générer un PDF depuis une facture
"actions": {
"generate_tracking_pdf": {
"entity_type": "invoice",
"label": { "fr": "Fiche de suivi PDF", "en": "Tracking sheet PDF" },
"icon": "fas fa-file-pdf",
"color": "danger",
"type": "generate_pdf",
"pdf_template": "tracking_sheet"
}
}Exemple : naviguer vers une page du plugin
"actions": {
"view_deals": {
"entity_type": "invoice",
"label": { "fr": "Voir les deals liés", "en": "View linked deals" },
"icon": "fas fa-handshake",
"color": "info",
"type": "navigate",
"target_page": "deals_list"
}
}Entités supportées
contact n'est pas dans cette liste : rien n'affiche le menu "Action" sur les fiches client/fournisseur, contrairement aux onglets (section précédente) qui eux le supportent. Une action déclarée avec "entity_type": "contact" est acceptée sans erreur mais n'apparaît nulle part.
invoiceexpenseestimateorderdeliveryexit_vouchercreditreceiptservicepaymentCouleurs disponibles
primarysuccessdangerwarninginfoAction guards
Guards allow your extension to block Iberis actions if certain conditions are not met. For example, preventing a payment if no approved payment request exists.
Exemple : bloquer un paiement sans demande approuvée
"guards": {
"require_payment_request": {
"event": "payment.before_create",
"conditions": [],
"check": {
"type": "require_record",
"model": "payment_request",
"where": {
"expense_id": "hashed_expense_id"
},
"status": 3
},
"message": {
"fr": "Un paiement ne peut être effectué sans demande de paiement approuvée.",
"en": "Payment cannot be made without an approved payment request."
}
}
}Exemple : bloquer la suppression si champ non vide
"guards": {
"prevent_delete_if_linked": {
"event": "invoice.before_delete",
"check": {
"type": "require_field",
"field": "external_ref"
},
"message": {
"fr": "Cette facture est liée à un système externe et ne peut pas être supprimée."
}
}
}Exemple : bloquer la validation avec expression
"guards": {
"min_amount": {
"event": "expense.before_validate",
"check": {
"type": "expression",
"expression": "total >= 10"
},
"message": {
"fr": "Le montant minimum pour valider une dépense est de 10 TND."
}
}
}Événements guard disponibles
Liste exhaustive — contrairement aux mutators et aux hooks d'événements, les guards ne sont câblés que sur ces 7 points précis. Un guard déclaré sur un autre événement (ex. order.before_validate) est accepté à la soumission mais ne se déclenchera jamais, sans erreur.
payment.before_createinvoice.before_validateinvoice.before_deleteexpense.before_validateexpense.before_deleteestimate.before_validateestimate.before_deletePDF templates
Generate custom PDF documents from your model data. Use HTML/CSS with variables and loops to create professional templates.
Exemple : demande de paiement PDF
"pdf_templates": {
"demande_paiement": {
"model": "payment_request",
"format": "A4",
"orientation": "P",
"margin_top": 15,
"margin_bottom": 15,
"margin_left": 10,
"margin_right": 10,
"data": {
"lines": {
"model": "@request_line",
"type": "list",
"where": { "request_id": "id" },
"order_by": "created_at"
},
"bank": {
"model": "company_banks",
"type": "single",
"where": { "id": "bank_id" }
}
},
"css": "body { font-family: Arial, sans-serif; font-size: 12px; } .header { text-align: center; margin-bottom: 20px; } .title { font-size: 18px; font-weight: bold; } table { width: 100%; border-collapse: collapse; margin: 15px 0; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } th { background: #f5f5f5; } .signatures { display: flex; justify-content: space-between; margin-top: 40px; } .sig-block { text-align: center; width: 30%; } .sig-line { border-top: 1px solid #333; margin-top: 50px; padding-top: 5px; }",
"html": "<div class='header'><div class='title'>DEMANDE DE PAIEMENT</div><div>N: {{number}} - Date: {{date}}</div><div>{{company.title}}</div></div><table><tr><th>#</th><th>Description</th><th>Montant</th></tr>{{#each related.lines}}<tr><td>{{_index}}</td><td>{{item.description}}</td><td>{{item.amount}} TND</td></tr>{{/each}}</table><p><strong>Total: {{total}} TND</strong></p><p>Banque: {{related.bank.title}} - RIB: {{related.bank.rib}}</p><div class='signatures'><div class='sig-block'><div class='sig-line'>Charge des moyens généraux</div></div><div class='sig-block'><div class='sig-line'>Direction Générale</div></div><div class='sig-block'><div class='sig-line'>Direction Financière</div></div></div>"
}
}Variables simples
{{field_name}} — Champ du record principal{{company.title}} — Données entreprise ({{company.address}}, {{company.fiscal_id}}){{related.alias.field}} — Donnée liée (single){{today}} ou {{today:d/m/Y}} — Date du jourFormatage
// Formater un nombre (3 décimales par défaut)
{{number:amount:2}} → 1 500.00
{{number:item.price:3}} → 25.500
// Formater une date (format PHP)
{{date:created_at:d/m/Y}} → 19/03/2026
{{date:item.due:Y-m-d}} → 2026-03-19
// Somme d'un champ dans une collection
{{sum:related.lines.amount:2}} → 4 250.00
// Nombre d'éléments
{{count:related.lines}} → 5
// Image dynamique (logo, signature)
{{image:company.logo:200}} → <img> 200px de large
{{image:company.stamp:150:150}} → <img> 150x150pxConditions
// Afficher un bloc si la condition est vraie
{{#if status == 3}}
<div class="badge-success">APPROUVÉ</div>
{{#else}}
<div class="badge-warning">EN ATTENTE</div>
{{/if}}
// Masquer si la condition est vraie
{{#unless total == 0}}
<p>Total: {{number:total:3}} TND</p>
{{/unless}}
// Conditions dans les boucles
{{#each related.lines}}
<tr>
<td>{{item.description}}</td>
<td>{{number:item.amount:2}}</td>
{{#if item.status == 1}}
<td class="text-success">Payé</td>
{{#else}}
<td class="text-danger">Impayé</td>
{{/if}}
</tr>
{{/each}}Boucles
{{#each related.lines}}
<tr>
<td>{{_index}}</td>
<td>{{item.description}}</td>
<td>{{number:item.quantity:0}}</td>
<td>{{number:item.unit_price:3}}</td>
<td>{{number:item.line_total:3}}</td>
</tr>
{{/each}}parent), ils sont automatiquement chargés comme données liées sans avoir besoin de les déclarer dans data. Le nom de l'alias est le nom du model enfant. Opérateurs de comparaison
== égal, != différent, > supérieur, < inférieur, >= supérieur ou égal, <= inférieur ou égalAPI
// Générer le PDF (retourne le chemin du fichier)
POST /api/private/company/{companyId}/plugins/{slug}/pdf/{templateName}/{recordId}
// Télécharger directement le PDF
GET /api/private/company/{companyId}/plugins/{slug}/pdf/{templateName}/{recordId}/downloadModèles personnalisés (documents Iberis)
À ne pas confondre avec pdf_templates (qui sert à générer des PDF à partir de vos models de plugin), la section custom_templates permet d'installer des modèles HTML/CSS qui s'ajoutent au sélecteur de modèles natif d'Iberis pour les documents du cœur Iberis (factures, devis, livraisons, etc.). C'est le moyen idéal pour vendre des packs de designs premium.
custom_templates — la section models n'est plus obligatoire dans ce cas. Vous pouvez livrer un pack de modèles sans aucun model, page ou hook. Cycle de vie
custom_templates est insérée dans la table custom_templates de l'entreprise avec plugin_slug = slug du plugin. Les modèles apparaissent immédiatement dans Paramètres → Modèles PDF.plugin_slug sont supprimés. La désinstallation ne touche pas le modèle sélectionné par l'entreprise (pdf_templates) : si un de vos modèles était choisi comme défaut, cette référence reste en place mais pointe désormais dans le vide. C'est au moment de chaque génération de PDF que le moteur détecte l'absence du modèle et retombe sur compact — pas une seule fois à la désinstallation.updateOrCreate sur (company_id, plugin_slug, name, document_type). Le HTML, le CSS et l'aperçu sont écrasés.document_type accepte une chaîne OU un tableau de chaînes. Si vous passez un tableau, le moteur d'installation crée automatiquement une ligne par type de document — un seul modèle "Tribune" peut donc apparaître à la fois dans le sélecteur de factures, devis et factures fournisseurs sans dupliquer le HTML/CSS dans le manifest.Types de documents supportés
La valeur de document_type doit être l'une des suivantes (chaîne ou tableau) :
invoice, estimate, delivery, credit, order, exitVoucher, paymentSaleexpense, paymentPurchasereceipt, inventory, entry, disbursementStructure du manifest
{
"plugin": {
"id": "premium-invoice-templates",
"name": { "fr": "Modèles de Factures Premium" },
"description": { "fr": "3 modèles de factures conçus professionnellement." },
"version": "1.0.0",
"category": "finance",
"module": "finance"
},
"custom_templates": [
{
"name": "Tribune",
"document_type": ["invoice", "estimate", "expense"],
"css_content": "body { font-family: Helvetica; ... }",
"html_content": "<div class='wrap'>...</div>",
"preview_image": null
},
{
"name": "Boutique",
"document_type": "invoice",
"css_content": "body { font-family: Arial; ... }",
"html_content": "<div class='bar'>...</div>"
}
]
}Champs d'une entrée
name (requis) — Nom affiché dans le sélecteur. Accepte une chaîne ou un objet i18n (le en sera utilisé comme repli).document_type (requis) — Le type de document Iberis ciblé. Accepte une chaîne ("invoice") ou un tableau (["invoice", "estimate", "expense"]) pour rendre le même modèle disponible sur plusieurs types de documents en une seule entrée.html_content (requis) — Le HTML du modèle. Aucune balise <script>, aucune URL javascript:, aucun gestionnaire d'événement (onclick, etc.) — la validation rejette le manifest sinon.css_content (optionnel) — CSS inline du modèle. Tout doit être autonome — pas de feuilles de style externes.preview_image (optionnel) — Chemin ou data-URI vers une image d'aperçu pour le sélecteur.Variables disponibles dans le HTML/CSS
Le moteur de rendu utilise un remplacement {{variable.path}} simple (pas de Handlebars complet — pas de {{#each}} ni de {{#if}} dans les custom_templates). Les tableaux dynamiques sont insérés via des macros spéciales.
// Entreprise
{{company.name}} {{company.address}} {{company.phone}}
{{company.email}} {{company.fiscal_id}} {{company.website}}
{{company.city}} {{company.postal_code}} {{company.country}}
{{company.logo}} // se rend en <img> auto-dimensionné
{{company.stamp}} // se rend en <img> auto-dimensionné
{{company.pdf_header}} // en-tête répété sur chaque page (mPDF)
{{company.pdf_footer}} // pied de page répété sur chaque page (mPDF)
// Document (facture, devis, etc.)
{{document.number}} {{document.date}} {{document.due_date}}
{{document.object}} {{document.reference}} {{document.currency}}
{{document.subtotal}} {{document.tax_total}} {{document.discount}}
{{document.total}} {{document.paid}} {{document.balance}}
{{document.credits}} {{document.withholding}} {{document.total_words}}
{{document.notes}} {{document.conditions}}
// Contact (client / fournisseur)
{{contact.name}} {{contact.organisation}} {{contact.address}}
{{contact.phone}} {{contact.email}} {{contact.fiscal_id}}
{{contact.personal_id}} {{contact.city}} {{contact.postal_code}}
{{contact.country}}
{{contact.billing_address}} {{contact.billing_city}} {{contact.billing_zip}}
{{contact.delivery_address}} {{contact.delivery_city}} {{contact.delivery_zip}}
// Banque (depuis la facture)
{{bank.name}} {{bank.iban}} {{bank.bic}}
{{bank.rib}}
// Date
{{date.today}} {{date.year}}
// Tableaux dynamiques (rendus en <table> complet)
{{items.table}} // lignes du document
{{taxes.table}} // récapitulatif TVA
{{additional_inputs.table}} // frais/remises additionnels : libellé et montant,
// sans en-tête ni bordure. À placer dans le bloc
// des totaux, comme les lignes sous-total et TVA.
{{payment.table}} // historique des paiements
{{inventory.table}} // mouvements d'inventaire
{{journal_entry.table}} // écritures comptables
{{disbursement.table}} // détails d'un décaissement
// Champs individuels du document courant (ne remplacent le tableau
// ci-dessus que si vous voulez un seul champ isolé, pas la liste complète ;
// chaque groupe n'a de valeur que sur le type de document correspondant :
// paymentSale/paymentPurchase pour payment.*, inventory pour inventory.*,
// entry pour journal_entry.*)
{{payment.method}} {{payment.bank_fees}} {{payment.amount}}
{{payment.reference}}
{{inventory.user}} {{inventory.warehouse}}
{{journal_entry.number}} {{journal_entry.total}}
{{journal_entry.debit_total}} {{journal_entry.credit_total}}{{items.table}} : les colonnes affichées (référence, remise, TVA, lot / numéro de série, colonnes personnalisées…) ne se choisissent pas dans le template — elles suivent les paramètres PDF de l'entreprise (Paramètres → Modèles PDF → Colonnes) et, pour certaines, un réglage propre à chaque document. Votre template ne contrôle que l'endroit où le tableau s'affiche, pas sa composition. custom_templates n'utilisent pas la même syntaxe que les pdf_templates de plugin. Pas de {{#each}}, pas de {{#if}}, pas de formateurs {{number:...}} ou {{date:...}}. C'est un remplacement de variables simple par str_replace. Les tableaux dynamiques sont des macros ({{items.table}}) et la mise en forme des montants est gérée par le moteur de rendu Iberis selon la devise du document. {{company.pdf_header}} / {{company.pdf_footer}} : contrairement à {{company.logo}}/{{company.stamp}} qui s'insèrent comme du contenu normal, ces deux-là sont extraites du HTML et confiées à mPDF pour se répéter automatiquement sur chaque page — ne les mettez pas dans une boucle ou une structure conditionnelle, elles n'ont besoin d'apparaître qu'une fois dans le template. Si l'entreprise a activé « Visibilité de l'en-tête / pied de page par document » dans ses paramètres PDF, un document individuel peut désactiver ces images même si la société en a configuré — ne soyez pas surpris si elles n'apparaissent pas sur tous les documents générés. Masquage automatique des lignes vides
Le moteur de rendu masque automatiquement les lignes ou éléments dont la seule valeur dynamique est vide — aucun besoin de CSS :empty ou de logique conditionnelle. Écrivez simplement votre libellé et sa variable dans la même structure et le moteur fait le ménage.
<tr><td>Remise</td><td>{{document.discount}}</td></tr> — si discount est vide, la ligne entière disparaît (libellé compris).<td>Prix</td><td>:</td><td>{{x}}</td>), afin que le séparateur ne reste pas orphelin.<td> de la ligne contient une variable remplie, la ligne est conservée et seule la cellule vide (+ son libellé adjacent) est vidée — l'alignement des colonnes reste intact.<ul>/<ol>, le <li> contenant la variable vide est retiré ; le reste de la liste est préservé.<dl>, un <dd>{{x}}</dd> vide fait disparaître aussi son <dt> précédent, sans toucher aux autres paires de la liste.<p>Date : {{document.date}}</p> ou <span>{{x}}</span> dont la variable est vide voit l'élément parent direct entier supprimé. Attention : tout contenu statique présent dans ce parent disparaît aussi — gardez une seule variable par élément pour éviter les surprises.<td>, <li> ou <p> contient à la fois une variable vide et une variable remplie (ex. <td>{{a}} / {{b}}</td>), le moteur préserve la cellule pour ne pas perdre la donnée remplie — le libellé associé à la variable vide reste visible. Placez chaque couple libellé/valeur dans sa propre cellule / ligne pour bénéficier du masquage. Exemple complet d'une entrée
Cet exemple utilise un seul document_type en tableau pour cibler facture, devis et facture fournisseur en une seule entrée. Notez l'usage de vraies balises <table> avec des attributs width HTML — mPDF rend les tables natives bien plus fiablement que les divs avec display:table-cell. Aucun CSS :empty n'est nécessaire : les lignes de totaux dont la variable est vide (remise, retenue, crédits…) disparaissent d'elles-mêmes grâce au masquage automatique décrit plus haut.
{
"name": "Tribune",
"document_type": ["invoice", "estimate", "expense"],
"css_content": "body{font-family:Helvetica,Arial,sans-serif;font-size:10pt;color:#0a0a0a} .head{background:#0a0a0a;color:#fff;padding:24px} .num{font-size:32pt;font-weight:bold;letter-spacing:-1px} .row td{padding:8px 0;border-bottom:1px solid #eee} .row .l{color:#737373;text-transform:uppercase;font-size:8pt;letter-spacing:1px;padding-right:14px} .row .v{text-align:right;font-weight:600} .grand td{border-top:2px solid #0a0a0a;font-size:14pt;font-weight:bold}",
"html_content": "<table width='100%' cellpadding='0' cellspacing='0'><tr><td class='head'><strong>{{company.name}}</strong> · MF {{company.fiscal_id}}<br><span class='num'>{{document.number}}</span></td></tr></table><table width='100%' cellpadding='0' cellspacing='0'><tr><td>Émise à</td><td>{{contact.name}}</td></tr><tr><td>Organisation</td><td>{{contact.organisation}}</td></tr><tr><td>Adresse</td><td>{{contact.address}}</td></tr><tr><td>Date</td><td>{{document.date}}</td></tr><tr><td>Échéance</td><td>{{document.due_date}}</td></tr></table>{{items.table}}{{taxes.table}}<table width='100%' cellpadding='0' cellspacing='0'><tr><td width='50%'></td><td width='50%'><table width='100%' cellpadding='0' cellspacing='0'><tr class='row'><td class='l'>Sous-total HT</td><td class='v'>{{document.subtotal}}</td></tr><tr class='row'><td class='l'>Remise</td><td class='v'>{{document.discount}}</td></tr><tr class='row'><td class='l'>TVA</td><td class='v'>{{document.tax_total}}</td></tr><tr class='row'><td class='l'>Retenue à la source</td><td class='v'>{{document.withholding}}</td></tr><tr class='row'><td class='l'>Avoirs appliqués</td><td class='v'>{{document.credits}}</td></tr><tr class='row grand'><td class='l'>Total TTC</td><td class='v'>{{document.total}}</td></tr></table></td></tr></table><p><em>{{document.total_words}}</em></p><table width='100%' cellpadding='0' cellspacing='0'><tr><td>Banque</td><td>{{bank.name}}</td></tr><tr><td>RIB</td><td>{{bank.rib}}</td></tr></table>{{company.stamp}}"
}display:flex, display:grid et display:table-cell sur des <div> — ils ne se rendent pas de manière fiable. Utilisez plutôt de vraies balises <table> avec attributs HTML width="N%", cellpadding, cellspacing. Évitez aussi les pseudo-éléments ::before/::after avec content pour afficher des libellés — mPDF les ignore souvent. Mettez les libellés dans de vrais <td>. Validation à la soumission
name, document_type et html_content.document_type doit être une chaîne ou un tableau de chaînes, et chaque valeur doit être dans la liste autorisée.<script>, javascript: et les gestionnaires d'événements on*= — rejet immédiat si trouvés.company_id + plugin_slug, donc chaque entreprise a sa propre copie. La désinstallation pour une entreprise n'affecte pas les autres. Approval workflows
Add a validation workflow to your models. Define approval steps with specific roles or users. Two modes are available: sequential (strict order) and free (single approval sufficient).
Exemple : circuit DG → DFC
"models": {
"payment_request": {
"fields": {
"title": { "type": "string", "required": true },
"total": { "type": "decimal" },
"status": { "type": "integer", "default": 0 },
"expense_id": { "type": "reference", "references": "expenses" }
},
"statuses": {
"0": { "fr": "Brouillon", "en": "Draft" },
"1": { "fr": "Soumis", "en": "Submitted" },
"2": { "fr": "Approuvé DG", "en": "CEO Approved" },
"3": { "fr": "Valide DFC", "en": "CFO Validated" },
"4": { "fr": "Rejeté", "en": "Rejected" }
},
"approval_workflow": {
"approval_mode": "sequential",
"steps": [
{
"label": { "fr": "Validation Direction Générale", "en": "CEO Approval" },
"role": "manager"
},
{
"label": { "fr": "Validation Direction Financière", "en": "CFO Approval" },
"role": "accountant"
}
],
"on_approve": { "set_status": 3 },
"on_reject": { "set_status": 4 }
}
}
}Modes d'approbation
role d'une étape n'est pas vérifié : il est purement informatif — affiché dans l'état du workflow, jamais comparé à l'utilisateur qui appelle l'API d'approbation. Concrètement, l'exemple ci-dessus n'empêche personne : n'importe quel utilisateur ayant le droit d'agir sur le model payment_request peut approuver ou rejeter n'importe quelle étape, y compris "Validation Direction Financière", sans avoir le rôle accountant. Si votre circuit doit réellement restreindre qui approuve quoi, ce contrôle est à faire vous-même côté plugin (ex. un guard, ou vérifier le rôle dans votre propre logique avant d'appeler l'API). API
// Consulter l'état du workflow
GET /api/private/company/{companyId}/plugins/{slug}/{model}/{recordId}/approval
// Approuver (status=1) ou rejeter (status=2) une étape
POST /api/private/company/{companyId}/plugins/{slug}/{model}/{recordId}/approval
Body: { "step_index": 0, "status": 1, "comment": "OK" }Mutators (data transformation)
Les mutators permettent de modifier automatiquement les données d'un document Iberis avant sa sauvegarde. Contrairement aux guards qui bloquent, les mutators transforment.
Exemple : forcer une valeur par défaut
"mutators": {
"default_payment_terms": {
"event": "invoice.before_save",
"default": {
"conditions": "Paiement à 30 jours"
}
}
}Exemple : calculer et forcer une valeur
"mutators": {
"auto_reference": {
"event": "expense.before_save",
"conditions": [
{ "field": "status", "equals": 0 }
],
"set": {
"reference": "CONCAT('REF-', order_number)"
}
}
}conditions : Optionnel. Le mutator ne s'applique que si les conditions sont remplies.
Événements disponibles
invoice.before_saveexpense.before_saveestimate.before_savepayment.before_saveitem.before_saveorder.before_savedelivery.before_savecredit.before_savereceipt.before_saveservice.before_saveexit_voucher.before_saveDashboard pages
Créez des pages qui affichent des widgets et des tableaux de données de plusieurs models en une seule vue. Idéal pour les tableaux de bord, les rapports, ou les vues d'ensemble.
Exemple
"pages": {
"crm_dashboard": {
"type": "dashboard",
"title": { "fr": "Tableau de bord CRM", "en": "CRM Dashboard" },
"widgets": ["total_deals", "won_deals", "deals_by_status"],
"sections": [
{
"model": "deal",
"title": { "fr": "Deals récents", "en": "Recent deals" },
"columns": ["_number", "title", "amount", "status"],
"limit": 5,
"filter": {}
},
{
"model": "activity",
"title": { "fr": "Dernières activités", "en": "Latest activities" },
"columns": ["_number", "type", "description", "date"],
"limit": 10
}
]
}
}Structure
widgets du manifest){ "status": 2 }reference, la valeur attendue est le hashed id de l'enregistrement référencé ; pour tout autre type, la valeur brute (ex. l'entier d'un status). Égalité stricte uniquement — pas d'opérateurs (>, in, etc.). Un nom de champ qui n'existe pas sur le model est silencieusement ignoré. form existe pour ce model). Import / export CSV
Export et import CSV sont désactivés par défaut. Chaque modèle qui doit les proposer le déclare explicitement :
{
"models": {
"reservation": {
"export": true,
"import": true,
"fields": { ... }
}
}
}Sans export/import sur le modèle, le bouton correspondant n'apparaît pas sur la page liste et l'endpoint renvoie 404 — pas 403, la fonctionnalité n'existe simplement pas pour ce modèle.
Une fois activés, ils utilisent par défaut canRead (export) / canCreate (import). Pour exiger une permission dédiée à la place, déclarez canExport/canImport dans permissions :
"permissions": ["canRead", "canCreate", "canUpdate", "canDelete", "canExport", "canImport"]Export
;)id, company_id, deleted_atnumber, created_at, updated_at en lecture seuleImport
number, created_at, updated_at sont ignorées à l'importrequired et les types sont vérifiésAPI
// Export CSV
GET /api/private/company/{companyId}/plugins/{slug}/{model}/export
→ Retourne un fichier CSV en téléchargement
// Import CSV
POST /api/private/company/{companyId}/plugins/{slug}/{model}/import
Body: FormData avec champ "file" (CSV)
→ Retourne: { imported: 15, errors: ["Line 3: column count mismatch"] }; (point-virgule), encodage UTF-8, valeurs entre guillemets doubles. La première ligne contient les noms de colonnes. Master-detail relations
Créez des relations parent-enfant entre vos models. Par exemple, une commande avec des lignes de commande, ou une demande de paiement avec des factures liées. Les lignes enfant sont éditables directement dans le formulaire du parent.
Exemple : commande avec lignes
"models": {
"order": {
"numbering": { "prefix": "CMD", "pattern": "{prefix}-{YYYY}-{sequence}" },
"fields": {
"title": { "type": "string", "required": true, "label": { "fr": "Titre" } },
"client_id": { "type": "reference", "references": "contacts", "label": { "fr": "Client" } },
"date": { "type": "date", "label": { "fr": "Date" } },
"status": { "type": "integer", "default": 0 },
"total": {
"type": "computed",
"formula": "SUM(order_line.quantity * order_line.unit_price)",
"label": { "fr": "Total" }
}
},
"statuses": {
"0": { "fr": "Brouillon" },
"1": { "fr": "Confirmée" },
"2": { "fr": "Livrée" }
}
},
"order_line": {
"label": { "fr": "Lignes de commande", "en": "Order lines" },
"parent": {
"model": "order",
"foreign_key": "order_id"
},
"fields": {
"order_id": { "type": "reference", "references": "@order" },
"description": { "type": "string", "required": true, "label": { "fr": "Description" } },
"quantity": { "type": "integer", "default": 1, "label": { "fr": "Quantité" } },
"unit_price": { "type": "decimal", "label": { "fr": "Prix unitaire" } },
"line_total": {
"type": "computed",
"formula": "quantity * unit_price",
"label": { "fr": "Total ligne" }
}
}
}
}Comportement
computed qui agrège les lignes est à jour quand le hook le lit (l'ordre est : événements des enfants, puis celui du parent).GET /{lang}/api/private/company/{companyId}/plugins/{slug}/{model}/{id} retourne les enfants dans _children. En écriture, passez _children dans la même requête que le parent : { "_children": { "ligne": { "upsert": [...], "delete": ["hashed_id"] } } }. Les endpoints individuels des enfants restent disponibles.string, integer, decimal, date, boolean, reference et textarea y sont modifiables. Les champs computed, file et image n'y apparaissent pas, et un status enfant s'affiche comme un simple nombre, pas comme un select. Si vos opérateurs doivent voir une valeur calculée par ligne, prévoyez-la dans le PDF ou dans une page liste dédiée. Déclaration
parent.foreign_key : Le champ du model enfant qui référence le parent (doit être de type
reference vers @parent_model)label : Le titre affiché au-dessus du tableau des lignes dans le formulaire
Autre exemple : demande de paiement avec factures liées
"models": {
"payment_request": {
"fields": {
"title": { "type": "string", "required": true },
"total": { "type": "computed", "formula": "SUM(request_line.amount)" },
"status": { "type": "integer", "default": 0 }
}
},
"request_line": {
"label": { "fr": "Factures liées", "en": "Linked invoices" },
"parent": { "model": "payment_request", "foreign_key": "request_id" },
"fields": {
"request_id": { "type": "reference", "references": "@payment_request" },
"expense_id": { "type": "reference", "references": "expenses", "label": { "fr": "Facture" } },
"amount": { "type": "decimal", "label": { "fr": "Montant" } },
"description": { "type": "string", "label": { "fr": "Description" } }
}
}
}Permissions by role
Contrôlez l'accès aux fonctionnalités de votre plugin par rôle d'utilisateur. Par défaut, 4 permissions sont créées automatiquement : canRead, canCreate, canUpdate, canDelete.
Permissions par défaut
Si vous ne déclarez pas de section permissions, les 4 permissions standard sont utilisées. Elles sont automatiquement ajoutées au rôle "Propriétaire" lors de l'installation.
// Implicite (pas besoin de le déclarer) :
"permissions": ["canRead", "canCreate", "canUpdate", "canDelete"]
// Les clés générées seront :
// plugin:mon-plugin:canRead
// plugin:mon-plugin:canCreate
// plugin:mon-plugin:canUpdate
// plugin:mon-plugin:canDeletePermissions personnalisées
"permissions": [
"canRead",
"canCreate",
"canUpdate",
"canDelete",
"canExport",
"canApprove",
"canManageSettings"
]plugin:{slug}:. Par exemple, canApprove devient plugin:mon-plugin:canApprove. C'est ce format qui est stocké dans le rôle de l'utilisateur. Comportement
canReadcanRead, création canCreate, modification canUpdate, suppression canDeleteUtiliser dans les menus
"menus": [{
"section": {
"title": { "fr": "Mon plugin" },
"items": [
{
"label": { "fr": "Liste" },
"page": "list_page",
"permission": "canRead"
},
{
"label": { "fr": "Paramètres" },
"page": "settings_page",
"permission": "canManageSettings"
}
]
}
}]Si permission n'est pas déclaré sur un item de menu, canRead est utilisé par défaut. Pour un item qui pointe vers une page liée à un modèle (list/form), voir ci-dessous : la permission du modèle prend le pas sur celle déclarée ici.
Restreindre un modèle à une permission dédiée
Par défaut, un modèle est couvert par les permissions standards du plugin (canRead/canCreate/canUpdate/canDelete), au même titre que tous les autres modèles. Si un modèle en particulier doit être verrouillé à part — un écran plus sensible que le reste du plugin — déclarez permission directement sur ce modèle :
{
"models": {
"statement": {
"permission": "canValidateStatement",
"fields": { ... }
}
},
"permissions": ["canRead", "canCreate", "canUpdate", "canDelete", "canValidateStatement"]
}permissions du plugin (sinon la soumission est rejetée) — c'est ce qui la fait apparaître comme case à cocher dans l'éditeur de rôles. Une fois déclarée, cette permission remplace canRead/canCreate/canUpdate/canDelete pour toutes les actions sur ce modèle — lecture, création, modification, suppression, export, import, et les étapes d'approbation le cas échéant. Elle ne s'applique qu'à ce modèle : les autres modèles du même plugin continuent d'utiliser les permissions standards, indépendamment. Ce n'est pas plus fin qu'une permission par modèle — il n'y a pas de permission séparée par action au sein d'un même modèle.
Un item de menu qui pointe vers une page liée à ce modèle n'a pas besoin de déclarer sa propre permission : elle est automatiquement dérivée de celle du modèle, pour que le menu ne puisse jamais afficher — ou masquer — une entrée en désaccord avec ce que l'API autorise réellement.
Limits and security
Sécurité
- Isolation multi-tenant totale — toutes les requêtes sont scopées par
company_id - Pas de code exécutable — uniquement du JSON déclaratif interprété
- Webhooks et api_call — HTTPS uniquement
- HTML long_description — sanitisé (pas de script, pas d'event handlers)
- Formules — parseur AST sécurisé, pas de raw SQL
- Noms de tables/champs réservés — protégés contre les collisions
- Review manuelle par l'équipe Iberis avant publication















Facebook
Instagram
Linkedin