An official developer portal of the Government of the Republic of Zambia · Test environmentVerify a certificate
Pamodzi Developer HubBuild, integrate and learn the e-Services framework
On this page

Extension points

Core code never requires a module. When core needs module behaviour, it calls an extension point by name, and the modules that are on for the caller answer. The points are declared in one place, zcm-backend/lib/modules/points.js; the context each point receives and the answer it expects are documented in zcm-backend/docs/modules/README.md section 9. This page is the map. Read Building modules first.

How a module contributes

A module lists its contributions in modules/<id>/contributions.js, as { '<point>': fnOrValue }. The manifest points to it with contributes: () => require('./contributions'). A contribution to a point name that does not exist fails manifest validation at boot.

// modules/fisheries/contributions.js
module.exports = {
  // a figure on the back-office dashboard
  'dashboard.widgets': async ({ mdaId }) => {
    const { FisheriesRecord } = require('../../models');
    const count = await FisheriesRecord.count({ where: mdaId ? { mdaId } : {} });
    return [{ key: 'fisheries.records', label: 'Fisheries records', data: { count } }];
  },

  // money: answer undefined when the payment is not yours; use ctx.transaction for every query
  'payment.onFinalised': async ({ invoice, transaction }) => {
    if (!invoice || !isOurs(invoice)) return undefined;
    await markLicencePaid(invoice, { transaction });
    return { updated: true };
  },
};

Calling modes

ModeCore callsResult
eachpoints.run(name, ctx)Every contribution, in dependency order; an array of results.
each, isolatedpoints.runIsolated(name, ctx)Every contribution in its own savepoint. A failure is rolled back alone and logged, never thrown.
firstpoints.first(name, ctx)The first answer that is not undefined.
collectpoints.collect(name, ctx)The arrays of every contribution, concatenated.
collect (sync)points.collectSync(name, ctx)The same, read at load time: values or synchronous functions only. A promise throws.
—points.has(name, ctx)Does an enabled module contribute?

Three rules apply to every point:

  • Only enabled modules take part, for ctx.mdaId, else the ministry of ctx.user, else the installation. With

    no module on, every point is a no-op ([] or undefined).

  • Transactions belong to the caller. Use ctx.transaction for every query; never open or commit your own. An

    exception in a contribution reaches the caller and rolls its transaction back (except on isolated points).

  • Bypass points also reach a bundled module that is switched off. They exist for the money rule: a payment that

    arrives for a module switched off since is still finalised by that module. When a switched-off module acts on a bypass point, the run is audited as module.bypass. The bypass points are payment.onFinalised, payment.finaliseExternalInvoice, billing.onAllocated and pos.offlineSale.

Separately, the personal-data and register registries (softDelete.kinds, pii.*, anonymise.*, dsr.exportSections, registers.packs, migration.legacyImports) are read for every bundled module, on or off, through lib/moduleData.js. Masking, deletion and a person's data export never depend on a switch.

Reference

Services and applications

PointModePurpose
service.capabilitiescollect (sync)The service capabilities a module owns (from the manifest's serviceCapabilities).
case.initialStatusfirstThe status a new case starts in, for the module's services.
case.onSubmiteachAct when an application is submitted, inside the submission's transaction.
case.statusChangedeachA case moved to another status; runs after the move committed. A failure is logged, the move stands.
case.onDecisioneach, isolatedHow an application ended (approve, reject, withdraw, lapse), on every path that records it. A module can never stop a decision.
case.onApprovedeachAct when a case is approved (issuance).
case.stepGatefirstMay the case leave this step? For step types the module contributes.
signature.completedeachEvery signer of a signature request signed; runs after the commit.
form.validatorscollectCross-field checks the form engine cannot express, run on the server at submission.
form.prefillcollectLook-ups that fill a service's form from a ministry system.
form.fieldRolescollect (sync)Roles a form field can play for the module.
service.requiredDocumentscollectDocuments a ministry system asks for, given the answers.
allocation.claimscollectWhat an application draws from the module's allocation (quota) pools.
names.sourcescollectNames in an outside register, for the generic name search.

Service designer and vocabulary

PointModePurpose
designer.feeTypescollect (sync)Fee types the designer offers.
designer.stepTypescollect (sync)Process step types the designer offers.
status.vocabularycollect (sync)Plain words for the module's statuses.

Payments and billing

PointModePurpose
payment.quotefirstWork out an amount on the server, with optional lines.
fee.proposalCheckfirstMay finance approve this officer-computed fee?
payment.onFinalisedeach, bypassA payment was finalised; update the module's own records.
payment.finaliseExternalInvoicefirst, bypassFinalise an invoice the module raised outside a case.
payment.payerResolverfirstName and contacts of the payer for a payment.
payment.kindscollectKinds of payment the module raises, for finance screens.
payment.accountOrderfirstAn order against one of the citizen's accounts, priced on the server.
payment.pointDescribe / payment.pointOrder / payment.pointTicketfirstPay-at-point QR codes: describe the charge, raise the session's ticket invoice, report the ticket. Never mark anything paid.
billing.kindscollect (sync)Kinds of billing account the module owns, and their amounts.
billing.onAllocatedeach, bypassMoney was allocated on one of the module's billing accounts.

Documents, permits and verification

PointModePurpose
documents.templatesfirstMerge data, or a rendered file, for an output document.
documents.valuescollectNamed values added to a case's output document. Core values win.
permit.issueOptionsfirstValidity, conditions or metadata for the permit core issues.
permit.detailscollectService-agnostic blocks shown with the holder's own permits on "My permits".
verify.lookupfirstThe module's own issued documents, for the public verification page.

Citizen views

PointModePurpose
citizen.actionscollectItems that wait on the signed-in person.
citizen.recordscollectRecords the module holds for the person (My records). Every word shown comes from here.
citizen.paymentscollectAccounts the person pays a regular charge on.

Back office, reporting and integration

PointModePurpose
dashboard.widgetscollectFigures on the back-office dashboard.
reports.sourcescollect (sync)Data sources for the report builder.
search.providerscollect (sync)Results for staff search.
registerEntry.tabscollectTabs on the register-entry 360 page. Show only what the caller may see.
registers.packscollect (sync)Register schema packs the module adds.
notifications.templatescollect (sync)Notification templates, editable on the templates screen.
webhooks.eventTypescollect (sync)Webhook event types added to the catalogue.
audit.objectTypescollect (sync)Audit object types.
openapi.pathscollect (sync)Paths added to the OpenAPI document.
dataExchange.handlerscollect (sync)Handlers for messages a ministry system pushes over the legacy data exchange contract.
migration.legacyImportscollect (sync)The module's import of legacy tables only it understands.
pos.capabilitiescollect (sync)POS capability names for the device's ministry.
pos.offlineSalefirst, bypassAn item of the terminal's offline queue (cash already taken is recorded).

Personal data

PointModePurpose
softDelete.kindscollect (sync)Record types for the recycle bin.
pii.subjects / pii.pathscollect (sync)Where the module keeps personal data, for masking.
anonymise.columnscollect (sync)How the anonymiser treats the module's columns.
anonymise.referenceTablescollect (sync)Tables without personal data, copied as they are.
dsr.exportSectionscollectThe module's part of a person's data export.

Maps, land and field work

PointModePurpose
geo.validatorscollectRules for the "Location / area on map" form field.
geo.layerRefreshedeachA reference layer was refreshed from the geodata provider.
geo.feedscollect (sync)Read-only GeoJSON feeds for partner systems (scope geo:read). No personal data.
geo.layerscollectLive read-only map layers for back-office maps, with an attribute allow-list.
geo.publicSearchcollect (sync)A register of shapes the public may search on "Find a property or parcel".
parcels.lookupfirstThe parcel register (a cadastre module); read through lib/parcels.js.
parcels.eventseachA parcel was created, retired, subdivided, consolidated or changed shape.
land.titleForfirstThe title registered on a parcel.
inspection.subjectLocationfirstWhere an inspected subject is, for on-site checks.
field.captureTargetscollectWhat an officer may capture in the field as a new, proposed record.
subject.completedeach, isolatedSomething was completed (for example a building at its occupancy certificate); emitted after the emitter's commit.

Laboratory

PointModePurpose
samples.subjectscollectKinds of record a laboratory sample without an application may name.
laboratory.samplesfirstThe laboratory itself, for other modules; read through lib/laboratorySamples.js.

Geo points are described in more detail in Geospatial data and NGDR.

Proposing a new generic point

The ground rule is: if a module needs a new hook, add a generic extension point to core and document it. Never put module logic into core files such as caseController or caseWorkflow.

  1. Check the list first. Many needs are already covered: case.onSubmit plus case.onDecision cover most

    lifecycle work, documents.values covers extra document fields, form.validators covers cross-field checks.

  2. Design it for every ministry. Name it after the core concept (permit.issueOptions, not

    forestry.licenceValidity). The context must carry only what any module would need, and never a ministry name.

  3. Pick the mode. first when one module owns the answer (answer undefined when it is not yours); collect

    when several modules add items; each for notifications of something that happened; isolated when a module must never be able to stop or undo what core records; bypass only for money that must still settle after a switch-off.

  4. Declare it in POINTS in lib/modules/points.js, with a comment giving the context and the expected answer.
  5. Call it from core by name, inside the caller's transaction where there is one. Make the "no module on" case

    behave exactly as before.

  6. Document it: add a row to section 9 of docs/modules/README.md (point, mode, context, answer). Points that

    form part of a frozen cross-team contract are also recorded in .overhaul/CONTRACTS.md.

  7. Test it: a unit test of the point, and an integration test with a test-only module

    (setup({ modules: [manifest] })) proving the off, on and failure behaviour. npm run check:modules and npm run check:mda must stay green.

Learn to use these points in practice in the Module developer course.