Skip to content

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.

  • 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.

    Block Hours card: 17 of 60 h consumed, the contract billing this ticket marked as such, after-hours badge

    Fixed Fee badge — no consumption bar for a model that has none

  • 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.

    Billing tab on a Block Hours client: labor summary, contract table, per-contract deep links

    The same tab for a client with no contract: uncontracted-work alert and an empty table

  • 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.

    A reply refused under Block Responses, with the reason on screen and nothing added to the ticket

  • 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.

    A ticket that lapsed and renewed twice, carrying exactly two warnings and two corrections

  • 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_status read-only tool for glpiai: 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.
Terminal window
# from the GLPI root
git clone https://github.com/bijstaan/glpi-entitle.git plugins/glpientitle
php bin/console plugin:install -u glpi glpientitle
php bin/console plugin:activate glpientitle

Nothing 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.

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.

Status card pointed at a live ERPNext

Per-entity diagnostics: cache age, last fetch outcome, endpoint version

The same table after a failed refresh: cached payload still served, error verbatim

Ticket form loads (pre_item_form)
│
▼
Entitlement::forEntity()
│ entity enabled? ──no──▶ nothing rendered
│ cache fresh? ──yes─▶ render from cache, no ERPNext call
▼ no
Client::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 age

The 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 refuse

ThresholdChecker (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.

Terminal window
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:

Terminal window
cd tests/browser && SHOT_DIR=../../docs/screenshots node entitle-check.js

Enforcement 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/.

Billing tab under the Dark Auror palette

  • 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.

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.