Company lookup: Moodle CRM and Companies House
Two external lookups turn a UK company registration number into a company
record: the School’s own CRM (exposed as a Moodle web service) and the
Companies House Company Profile API. Both feed cc_company, and the order
matters — the CRM is authoritative, Companies House is the fallback.
Why it exists
Section titled “Why it exists”Everything in the Carbon Calculator is keyed on the company, identified by CRN. Buyers upload supplier lists as spreadsheets of names, CRNs and spend, and those suppliers mostly are not School members. To do anything useful the system needs at minimum a canonical name, a registered address and a SIC code (for the spend-based emissions estimate). The CRM gives the richer record — including partner flags and real contact emails — when the company is a member.
Entry points
Section titled “Entry points”| Path | What it does |
|---|---|
wp-content/plugins/carboncalc/includes/MoodleCompany.php:19 | lookup($crn) — Moodle local_scssusers_company_details |
wp-content/plugins/carboncalc/includes/CompaniesHouse.php:44 | lookup($crn) — Companies House Company Profile |
wp-content/plugins/carboncalc/includes/UkCompanyCrn.php | normalize() / is_valid() |
wp-content/plugins/carboncalc/includes/LMS.php:958 | fetch_moodle_user_details_by_email() — local_scssusers_user_details |
wp-content/plugins/carboncalc/includes/LMS.php:1177 | upsert_company_from_moodle() — the login-time company sync |
wp-content/themes/carboncalculator/functions.php:3111 | $insert_company_from_crm (supplier upload) |
wp-content/themes/carboncalculator/functions.php:3149 | $insert_company_from_companies_house (supplier upload) |
wp-content/plugins/carboncalc/includes/AdminCompanyLookupPage.php | wp-admin → Carbon Calculator → Company Lookup |
How it works
Section titled “How it works”The two web services
Section titled “The two web services”Both Moodle calls are POST to {LMS_URL}webservice/rest/server.php with
wstoken, wsfunction, moodlewsrestformat=json. The comment at
LMS.php:953 notes GET is unreliable on some hosts, hence POST.
| Function | Input | Returns |
|---|---|---|
local_scssusers_user_details | email | user id, roles, nested company object |
local_scssusers_company_details | crn | company record with scs_company_* fields |
Companies House is GET https://api.company-information.service.gov.uk/company/{crn}
with HTTP Basic auth, the API key as the username and an empty password
(CompaniesHouse.php:69).
Field mapping
Section titled “Field mapping”MoodleCompany::mapToCcCompany() (:167) maps the CRM payload onto
cc_company columns:
| CRM key | Column |
|---|---|
name | name |
scs_company_email | email |
scs_company_domain | domain |
scs_company_country / _postalcode / _town / _address | address columns |
scs_company_housenumber | housenumber — and this is the CRN |
scs_company_phone, _website, _size, _departments, _markets | as named |
scs_is_cc_partner (fallback scs_is_partner) | is_partner |
| whole payload | profile_json |
first of cc_reporters, cc_admins, admins | contact_email (supplier joins) |
scs_company_housenumber holds the company registration number, not a
building number. This is stated twice in the code
(MoodleCompany.php:177, LMS.php:539) and it is the single most
confusing thing about this integration. CRN extraction tries, in order:
scs_company_housenumber, scs_company_registration_number,
scs_company_registrationnumber, company_registration_number,
companyregistrationnumber, crn (LMS.php:546).
Companies House returns much less: normalised CRN, company_name, the
first entry of sic_codes, the registered office address, and the raw
payload (CompaniesHouse.php:141). A company with several SIC codes loses
all but the first.
Resolution order
Section titled “Resolution order”At login (LMS::apply_user_details_sync(), :1030): user_details by
email → upsert cc_company from the nested company object → link
cc_users.moodle_company_id. If there is a company_id but no nested
company, a minimal stub row is inserted instead (:1097).
At supplier upload (functions.php:3001): existing cc_company by CRN →
Moodle CRM → Companies House. Only the CRM path sets
moodle_company_id and is_partner.
Partner-flag preservation
Section titled “Partner-flag preservation”The sync goes to some trouble not to lose partner status, because Moodle sometimes omits or zeroes the flag:
- If the payload has no CC-partner key, the previously stored value is
reused from
profile_jsonand from theis_partnercolumn (LMS.php:1212-1237). - If the payload says
0but the stored column says1,1wins — the comment atLMS.php:1243saysuser_detailssometimes sendsscs_is_cc_partner=0while the CRM already has partner=1. MoodleCompany::merge_stored_s3_partner_into_payload()(:103) does the same forcc_s3_partner, andenrich_s3_partner_from_crm_by_crn()(:125) fetches it from the CRM by CRN whenuser_detailsomitted it.
is_active_company_payload() (MoodleCompany.php:225) decides whether a
School company is active by probing a long list of possible keys
(suspended, deleted, status, is_active, …) and defaults to
active when none are present.
Logging
Section titled “Logging”Companies House logs every call as a JSON line to
wp-content/carboncalc-companieshouse.log — lookup_start,
lookup_request, lookup_success, lookup_not_found, lookup_http_error,
lookup_missing_api_key, lookup_json_invalid. CRM insert failures go to
wp-content/carboncalc-crm.log.
Configuration
Section titled “Configuration”| Name | Purpose |
|---|---|
COMPANIES_HOUSE_API_KEY | Companies House REST key; absent → every lookup returns null and logs lookup_missing_api_key |
MOODLE_WS_TOKEN | Moodle web-service token |
LMS_URL | Derived per blog in LMSClient.php, not configured directly |
Both Moodle helpers return null immediately if LMS_URL or
MOODLE_WS_TOKEN is undefined (MoodleCompany.php:21, LMS.php:960) — a
silent no-op, not an error.
Timeouts: 20s for Companies House and for user_details, 15s for
company_details.
Invariants and gotchas
Section titled “Invariants and gotchas”cc_company.crnandmoodle_company_idare both UNIQUE. Two Moodle companies sharing a CRN cannot both be stored; the second upsert collides.- A CRN found only at Companies House produces a company with no
moodle_company_idandis_partner = 0. It can never be a share target (share candidates requireis_partner = 1) and no user can be linked to it. - Companies House keeps only the first SIC code. If the emissions estimate looks wrong for a diversified company, this is why.
is_active_company_payload()defaults to true. A CRM payload that simply omits status information is treated as an active School company, which then unlocks the contact-email override in supplier uploads.- Lookups are uncached except the S3-partner result
(
cc_s3_partner_crm_{id}, one hour,LMS.php:325) and the per-request batch caches during supplier upload. The admin lookup screens hit the live APIs every time you load them. sync_roles_for_email()deletes allcc_user_rolesrows for the user then re-inserts (LMS.php:1050). If Moodle returns an empty roles array — including on a partial failure — the user ends up with no roles and drops to viewer-only.moodle_payload_gate_hints()flags this explicitly (LMS.php:760).- The gate calls these lookups as a side effect of what looks like a read.
Pass
$allow_writes = falsefrom diagnostics.
Changing it safely
Section titled “Changing it safely”- New CRM fields: add to
mapToCcCompany()and toupsert_company_from_moodle()’s INSERT/UPDATE list — the two do the same mapping in different places and will drift. The whole payload is already inprofile_json, so prefer reading from there over adding a column. - New CRN sources: add the key to the candidate list at
LMS.php:546, in priority order. Do not parse CRNs at call sites; useUkCompanyCrn::normalize(). - Adding a third provider: follow the supplier-upload order — existing row, CRM, then external. Insert the new provider after the CRM so member data always wins.
- Verify with wp-admin → Carbon Calculator → Company Lookup, which shows
the DB row, the live CRM payload and the gate diagnostics side by side for
a CRN, then tail
wp-content/carboncalc-companieshouse.log. - Deliberately not abstracted: the two lookup classes do not share an
interface. They return different shapes on purpose — the CRM returns
cc_company-ready columns, Companies House returns its own structure plusraw.
None. UkCompanyCrn, MoodleCompany::is_active_company_payload() and
is_s3_partner_company_payload() are all pure and would be quick wins.
Related: [[carbon-access-gates]], [[supply-chain-emissions]], [[moodle-sso-and-sessions]], [[carbon-admin-and-debug-tools]].