GLPI Entitle
Contract entitlement and block-hours visibility on the GLPI ticket, read from
the glpi_billing ERPNext app.
A technician spending a block-hours client’s time cannot see the balance, and a dispatcher approving out-of-scope work cannot see that it is out of scope. ERPNext already classifies every client’s contracts and computes both; this plugin puts that answer where the work happens.
Features
Section titled “Features”-
A card in the ticket’s fields panel — contract names, a model badge, a consumption bar for Block Hours contracts, an after-hours indicator, and a “not covered” hint when nothing bears the labor. Silent when there is nothing useful to say or the entity is not enabled.


-
A Billing tab — which contract this ticket’s labor lands on and what that means (covered / draws the prepaid block / billable hourly), every active contract with its terms and consumption, lapsed-contract and uncontracted-work alerts, and a deep link into the contract record in ERPNext. Shown only for enabled entities.


-
Uncontracted-work enforcement. For a client with no labor-bearing contract, ERPNext holds a per-client policy: bill at the uncontracted rate, require the client manager’s approval, or block responses. A refused reply or task explains itself; under the approval policy an approval request is raised once for whoever ERPNext names as approver. It fails open — if ERPNext is unreachable and nothing recent is cached, work is allowed.

-
Automatic public notices. When a task is logged on an uncontracted ticket, a public followup names the rate; when coverage returns, a second public followup withdraws it. Exactly one of each per lapse/renew cycle, however much work is logged inside it. Fixed template text — nothing generated and nothing quoted from the ticket — and one switch turns it off.

-
Threshold notifications. Once per contract per billing period, a Block Hours contract crossing the configured consumption percentage fires a real GLPI Notification with tags for contract, entity, percent and hours.
-
entitlement_statusread-only tool forglpiai: whether this entity’s work is covered and what is left of their block hours. Every answer carries its own age. There is nothing to write — the entitlement lives in ERPNext.
- No enforcement a client did not ask for. The card, the tab and the threshold notifications never block anything. The only gate is the uncontracted-work policy, which is set per client in ERPNext, can be switched off wholesale, and fails open whenever this plugin is unsure — including when the ERPNext endpoint is too old to carry a policy at all.
- No monetary amounts on screen, except inside the public notice, which has to name the rate it is warning about. Technicians see contract type and hours.
- No live “unbilled this period” hours. The consumption bar reflects work through the last processed ERPNext Billing Run. Computing it live would mean a second implementation of the rating engine inside a per-ticket-view call.
Install
Section titled “Install”# from the GLPI rootgit clone https://github.com/bijstaan/glpi-entitle.git plugins/glpientitlephp bin/console plugin:install -u glpi glpientitlephp bin/console plugin:activate glpientitleNothing appears on any ticket, and nothing is sent to ERPNext, until an ERPNext URL and API key/secret are set and reachable, and at least one entity is added to the allowlist.
Settings
Section titled “Settings”Setup → Plugins → GLPI Entitle.
| Setting | Default | Notes |
|---|---|---|
| ERPNext base URL | — | e.g. http://frontend:8080 |
| API key / API secret | — | Frappe API key pair; the secret is encrypted at rest |
| Cache freshness window | 3600s | Serve cached data without calling ERPNext |
| Serve stale data for up to | 86400s | Beyond the freshness window, on a failed refresh, before the card goes silent |
| Enabled entities | none | Default-off allowlist |
| Notify at consumption % | 80 | Threshold for the once-per-period Block Hours notification |
| Browser-reachable ERPNext URL | empty | For the Billing tab’s “open in ERPNext” links. Empty falls back to the base URL — set it when that is a container hostname a browser cannot resolve |
| Enforce the uncontracted-work policy | on | Acts on the client’s ERPNext policy when nothing covers their labor. Off = inform only |
| Post automatic notices to the requester | on | Public followups on uncontracted work and on its restoration |
| Debug mode | off | Shows a muted “entitlement unavailable” note instead of silence |
The two uncontracted-work switches are the only settings here that default to doing something.
Notification recipients are not set on this page. Add a target to Setup → Notifications → “Entitlement threshold crossed” and enable it; it ships installed but inactive.
The status card reports whether ERPNext is reachable, and per enabled entity how old the cached data is and what ERPNext last said when it refused, verbatim.



How it works
Section titled “How it works”Ticket form loads (pre_item_form) │ ▼Entitlement::forEntity() │ entity enabled? ──no──▶ nothing rendered │ cache fresh? ──yes─▶ render from cache, no ERPNext call ▼ noClient::fetchEntitlement() ── Guzzle, 2s connect / 4s total ──▶ ERPNext │ ok │ fails ▼ ▼Cache::recordSuccess() Cache::recordFailure() │ │ usable payload within the ▼ │ stale-serve window?render fresh ──yes─┤──no──▶ silent (debug: muted note) ▼ render stale, with ageThe uncontracted-work features read the same Entitlement::forEntity() path —
same cache row, same TTL, same stale-with-age rules. There is no second fetch
path: enforcement acting on data the card is not showing would be impossible to
explain to the person it just blocked.
Technician adds a followup or task (pre_item_add) │ ▼Enforcement::allows() │ feature switched off? ──▶ allow │ entity not enabled? ──▶ allow │ no usable cached payload? ──▶ allow ← fails open │ client has labor coverage? ──▶ allow ▼Policy::gate(payload, ticket.global_validation, has_approver) │ ├─ "Bill at Uncontracted Rate" ──▶ allow; on a logged task, │ PublicNotice::maybePost() ├─ "Block Responses" ──▶ refuse, with the reason on screen └─ "Require Customer Approval" ──▶ accepted? allow else raise one TicketValidation for the ERPNext-named approver and refuseThresholdChecker (hourly cron) reuses whatever is cached and never calls
ERPNext itself. It inserts a ThresholdEvent row per (contract, period) the
first time a contract crosses the threshold; the unique key on that pair is what
makes it once. A successful insert fires
NotificationEvent::raiseEvent('threshold', $event).
ProbeCron (every 5 minutes) refreshes the status card’s reachability check, so
rendering the settings page never makes a blocking live call.
Policy is pure — no DB, no GLPI — so every branch above including the
fail-open ones is exercised directly by tests/policy.php. It reads the
endpoint’s version-2 blocks tolerantly: an ERPNext too old to send
uncontracted makes the gate fail open and the settings page say so.
Everything that acts leaves a glpi_events row (service plugins, ticket as
subject): every refusal, every approval request, every notice.
docker exec glpi-glpi-1 sh -c 'cd /var/www/glpi/plugins/glpientitle && tests/run.sh'Standalone assert-style PHP, no PHPUnit. Everything is either DB-free pure logic
or run against a php -S mock of the ERPNext endpoint; nothing needs a live
GLPI or ERPNext.
The ERPNext-side maths has its own fixture-backed Frappe test module at
glpi_billing/glpi_billing/tests/test_entitle_api.py.
The rendering path, the Billing tab and the whole uncontracted-work story are checked in a browser:
cd tests/browser && SHOT_DIR=../../docs/screenshots node entitle-check.jsEnforcement and notices work in a fixture entity of their own, provisioned by the check, which maps to no ERPNext customer, so the endpoint answers with the install-wide policy and nothing covers its labor. Seeded billing entities are read-only in that run.
The same run does a dark-theme conformance pass on a plugin-owned test user
(glpientitle-darktest, never the real glpi user), asserting every class this
plugin renders reads at 4.5:1+ against its composited background on all three
dark palettes, and screenshots each surface to docs/screenshots/dark/.

Limitations
Section titled “Limitations”- The after-hours indicator compares the GLPI server clock against the entity’s working-days window. It does not know the ERPNext holiday calendar (only the Holiday List’s name travels in the payload, not its dates) and does not apply a contract’s client-timezone offset. Good enough for “rates may be higher right now”, not a billing computation.
- Consumption is as of the last processed Billing Run.
- Enforcement acts on stale cached data as well as fresh, never on silence. An ERPNext outage must not quietly switch off a policy a client pays to have enforced; the tab says when what it shows is cached, and how old.
- The public notice fires on logged work (a ticket task), not on every reply.
- The uncontracted-work policy and rate come from ERPNext and are not editable here.
- There are no billing panels for Projects or Software Licenses. Those read different ERPNext endpoints and have nothing to do with tickets.
Licence
Section titled “Licence”GPL-3.0-or-later, the same licence as GLPI. The plugin is loaded into GLPI’s process and extends its classes, so it is a derivative work. See LICENSE.