Skip to content

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.

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.

PathWhat it does
wp-content/plugins/carboncalc/includes/MoodleCompany.php:19lookup($crn) — Moodle local_scssusers_company_details
wp-content/plugins/carboncalc/includes/CompaniesHouse.php:44lookup($crn) — Companies House Company Profile
wp-content/plugins/carboncalc/includes/UkCompanyCrn.phpnormalize() / is_valid()
wp-content/plugins/carboncalc/includes/LMS.php:958fetch_moodle_user_details_by_email() — local_scssusers_user_details
wp-content/plugins/carboncalc/includes/LMS.php:1177upsert_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.phpwp-admin → Carbon Calculator → Company Lookup

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.

FunctionInputReturns
local_scssusers_user_detailsemailuser id, roles, nested company object
local_scssusers_company_detailscrncompany 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).

MoodleCompany::mapToCcCompany() (:167) maps the CRM payload onto cc_company columns:

CRM keyColumn
namename
scs_company_emailemail
scs_company_domaindomain
scs_company_country / _postalcode / _town / _addressaddress columns
scs_company_housenumberhousenumber — and this is the CRN
scs_company_phone, _website, _size, _departments, _marketsas named
scs_is_cc_partner (fallback scs_is_partner)is_partner
whole payloadprofile_json
first of cc_reporters, cc_admins, adminscontact_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.

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.

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_json and from the is_partner column (LMS.php:1212-1237).
  • If the payload says 0 but the stored column says 1, 1 wins — the comment at LMS.php:1243 says user_details sometimes sends scs_is_cc_partner=0 while the CRM already has partner=1.
  • MoodleCompany::merge_stored_s3_partner_into_payload() (:103) does the same for cc_s3_partner, and enrich_s3_partner_from_crm_by_crn() (:125) fetches it from the CRM by CRN when user_details omitted 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.

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.

NamePurpose
COMPANIES_HOUSE_API_KEYCompanies House REST key; absent → every lookup returns null and logs lookup_missing_api_key
MOODLE_WS_TOKENMoodle web-service token
LMS_URLDerived 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.

  • cc_company.crn and moodle_company_id are 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_id and is_partner = 0. It can never be a share target (share candidates require is_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 all cc_user_roles rows 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 = false from diagnostics.
  • New CRM fields: add to mapToCcCompany() and to upsert_company_from_moodle()’s INSERT/UPDATE list — the two do the same mapping in different places and will drift. The whole payload is already in profile_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; use UkCompanyCrn::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 plus raw.

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