Skip to content

Cloud Applications and Renewals Developer Guide

Ed Mozley edited this page Sep 10, 2026 · 1 revision

πŸ› οΈ Cloud applications and renewals β€” Developer Guide

How manually added applications coexist with agent discovery, and how licence renewals reach Watchtower and the calendar. Shipped as #1549–#1553 in 1.5.0.

The user-facing page is Cloud applications and licence renewals.


1. πŸ“ The files involved

Colour key: πŸ—„οΈ schema Β· βš™οΈ shared Β· πŸ”Œ API Β· πŸ–₯️ page Β· 🌍 i18n

🎨 File What it does
πŸ—„οΈ database/freeitsm.sql source, app_url, notes, created_by on software_inventory_apps
πŸ—„οΈ includes/db_verify_schema.php The same four, for an existing install
πŸ”Œ api/external/software-inventory/submit/index.php The guard. The agent may adopt a manual row, never overwrite it
πŸ”Œ api/software/save_app.php / delete_app.php Manual-only CRUD, with three delete guards
πŸ”Œ api/software/get_apps.php Adds source and the seat subquery
βš™οΈ includes/software_licence_calendar.php Renewals β†’ calendar. Twin of asset_warranty_calendar.php
βš™οΈ includes/services/software.php Resyncs the calendar after any licence write
βš™οΈ includes/watchtower_queries.php The Software card's three counts
βš™οΈ includes/watchtower_settings.php Registers the card; marks it impersonal
πŸ”Œ api/software/sync_renewal_calendar.php On-demand rebuild when the setting is saved
πŸ–₯️ software/index.php, software/settings/index.php, watchtower/index.php The UI

2. ⭐ The unlock nobody asked for directly

software_licences.app_id is NOT NULL with a foreign key to software_inventory_apps.

That single line is why this feature is worth more than "type in an app name". The entire licence machinery β€” renewal_date, notice_period_days, quantity, cost, currency, portal_url, vendor_contact, purchase_date β€” already existed and was completely unreachable for anything you don't install on a machine, because there was no app row to hang it on.

The only ways in were the inventory agent, the system-info submit and Intune. A cloud platform installs nothing, so nothing could discover it, so it could not be recorded at all.


3. πŸ”΄ The agent may ADOPT a manual row, but must never OVERWRITE it

The agent's lookup is:

SELECT id, display_name, publisher, source FROM software_inventory_apps
 WHERE display_name = ? AND (publisher IS NULL OR publisher = ?)

A hand-typed "Adobe Creative Cloud" with no publisher therefore already matches what an agent later reports. That match is correct and deliberate β€” the installs should attach to the row somebody already curated rather than starting a duplicate.

What must not happen is the rest of the block running on it:

if (($appRow['source'] ?? 'agent') !== 'manual') {
    // ... the "normalise to the latest values" update
}

The agent reports a registry DisplayName and Publisher. A person typed a name they chose, and possibly a URL and notes beside it. Letting the agent normalise would silently replace curated text with whatever an installer wrote into the registry, with nothing on screen to say it had happened. source stays 'manual' for the same reason: the row's origin is a fact about who is responsible for its fields, not about what has since been found installed.

Verified against the real endpoint

Posting a genuine agent payload naming a manual app returned updated_apps: 0, with the publisher, URL and notes intact and install_count going 0 β†’ 1. Without the guard that would have been updated_apps: 1 and a rewritten publisher.

⚠️ The submit endpoint authenticates with a plain Authorization: <key> header (no Bearer), against the apikeys table β€” not software_api_keys, which does not exist.


4. πŸͺ‘ Seats are not a fallback for installs

COUNT(DISTINCT d.host_id) as install_count,
(SELECT COALESCE(SUM(l.quantity), 0) FROM software_licences l
  WHERE l.app_id = a.id AND l.status = 'Active') as seats

⚠️ A correlated subquery, not a second LEFT JOIN. Joining two one-to-many tables in one GROUP BY multiplies the rows against each other, and install_count would become hosts Γ— licences. The same double-counting trap that a many-to-many ticket category would have been.

The two are reported side by side because they answer different questions. A cloud app is installed nowhere, and a 0 there must not be read as "nobody uses this".


5. πŸ“… Renewals β†’ the calendar

Reuses the mechanism asset warranties have used for a while rather than inventing one. calendar_events.source exists precisely so a generator can wipe and reinsert its own entries without touching anything a person typed:

$conn->exec("DELETE FROM calendar_events WHERE source = 'software_renewal'");

A cheap full resync β€” licence edits are rare, the event set is small, and regenerating the lot is the only version with no drift in it.

Two events per licence

The renewal date is when the money goes out. The notice deadline β€” renewal_date minus notice_period_days β€” is the last day you can still walk away, and it is the one that actually costs money. A calendar showing only the renewal tells you about the deadline on the day it is already too late.

Unlike contracts, which store their own notice_date column, this one is derived. A licence with no notice period gets no notice event: there is no deadline to miss, and defaulting to 30 days would put a fictional commitment in somebody's calendar.

It can never fail a licence save

private static function resyncRenewalCalendar(PDO $conn): void {
    try { … } catch (Throwable $e) { error_log(…); }
}

The licence is the record; its calendar entries are derived from it. A calendar table that is missing, mid-migration or momentarily locked must not turn "save this licence" into an error β€” the licence is already committed by the time this runs, so throwing would report a failure for something that succeeded.


6. πŸ“Š The Watchtower card

The gap this closes: software_licences has carried renewal_date and notice_period_days since it shipped, and both already drove a "due soon" colour β€” but only for somebody who happened to open the Licences page that week. A contract expiring the same day shouted from the dashboard.

Counted with the same three windows as Contracts (30 days, 90 days, notice periods due) so the two cards can be read against each other. The notice window is expressed the other way round, because the date is derived rather than stored:

DATE_SUB(renewal_date, INTERVAL notice_period_days DAY)
  BETWEEN {$todaySql} AND DATE_ADD({$todaySql}, INTERVAL 30 DAY)

A licence with no notice period is excluded rather than defaulted to 30 β€” an invented deadline is worse than none.

Registered as impersonal in wtImpersonalCards(). A licence has no owner column at all, so scoping it to a person would hide a Β£12k renewal from everybody β€” the same reasoning already applied to Contracts.

⚠️ No software_renewal_days

An equivalent of asset_warranty_days was built and then removed. This card reports three fixed windows, so a "warn me N days ahead" number would change none of them β€” a setting that visibly does nothing. Assets can afford one because its card has a single window for it to mean.

The surviving software_renewal_surface key is declared as setting_keys on the Renewals tab in software/settings/manifest.php and derived by settingKeyOwners(), rather than being listed in the explicit block in includes/settings_keys.php β€” that block is documented as System-only.

capSelfCheck() requires every Cap:: constant to be claimed by exactly one manifest tab. Adding SOFTWARE_RENEWALS without the tab fails it.


7. πŸ” A finding worth recording

The claim "nothing in FreeITSM reminds anyone about renewals" was wrong, and Ed corrected it.

Watchtower already surfaced contracts expiring (expiring_30d / expiring_90d / notice_periods_30d) and asset warranties. Software was the only one of the three never wired in.

The mistake behind the wrong claim: a grep of watchtower/ returned nothing, so the conclusion was "no reminders exist anywhere". The card data is built in includes/watchtower_queries.php, not in watchtower/. A negative from one shallow grep is not evidence of absence.


See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally