adapters/__init__.py — it never means touching the core. All bundled adapters (Google, Microsoft, Zoom, BambooHR) follow the same rules and are the best reference for what a production adapter looks like.
The three protocols
Each protocol is defined in
server/meetcost/adapters/base.py as an abstract base class. The data classes it uses — DirectoryPerson, PayImportRow, AttendanceRecord, MeetingRecord — are also defined there.
Rules every adapter follows
These five rules are not suggestions. They apply to every adapter, bundled or custom, and the egress guard enforces the last one regardless of what an adapter tries to do.
- Read-only. No adapter writes to an upstream system. If you find yourself needing a write scope, something has gone wrong.
- The org’s own tenant only. Adapters read from systems the organisation already owns, with credentials the operator supplied.
- Unconfigured means inert.
is_configured()returnsFalsewhen credentials are absent, and nothing is contacted. An unconfigured adapter is the expected state for platforms an organisation does not use — not an error. - Errors name the fix. Raise
AdapterNotConfiguredwith the environment variables an operator needs to set, not just “not configured”. - The egress guard applies regardless. An adapter that tries to reach a host outside the configured allowlist is refused at the socket layer. You do not need to defend against this; you need to know it will happen.
HRIS adapters
server/meetcost/adapters/hris_bamboohr.py is the reference implementation — about sixty lines of substance, showing the three things every HRIS adapter must do: authenticate, page through the full employee list, and map each record to a PayImportRow.
Class skeleton
Mapping pay correctly
This is where adapters go wrong most often. Annualise salaried staff, and only salaried staff. An HR system stores a rate plus a period. Multiply monthly by 12, biweekly by 26, semi-monthly by 24, weekly by 52, and so on.hris_bamboohr.py has the full multiplier table:
pay_basis=PayBasis.HOURLY and pass the hourly rate through untouched. The rate engine divides an hourly rate by 60 to get cost-per-minute and does not apply working_hours_per_year at all. Annualising an hourly rate and then dividing back down by an assumed 2,080-hour year reintroduces exactly the assumption that makes the figure wrong.
Skip rather than guess. A worker with no usable pay figure should be omitted, not defaulted to zero. Omitted people fall through to the org-wide fallback rate and are labelled as estimates; a zero silently understates the meeting.
Carry the currency. Do not convert. A meeting spanning currencies is refused with an explanation rather than summed into a meaningless number.
Set effective_date from the source. Records are versioned by it, so a meeting costed last quarter can still be explained with the rate that applied then.
Directory adapters
A directory adapter yieldsDirectoryPerson instances for every active employee. The sync marks anyone who disappears from a subsequent run as inactive rather than deleting them, because past meetings still need a name and a team to report against.
DirectoryPerson fields
Attendance collectors
An attendance collector returns aMeetingRecord for one finished meeting, or None if the provider has not published the record yet. It can also implement poll_recent for platforms without an end-of-meeting webhook.
fetch_meeting returning None is not an error
Providers publish attendance minutes to hours after a meeting ends. Returning None means “not yet” — which must be distinguishable from an empty MeetingRecord meaning “nobody came”. One is worth retrying; the other is a real, empty meeting. Never substitute an empty record for a missing one.
Four tricky attendance cases
Reconnections = one person
Zoom emits one row per join. Sum a reconnecting participant’s
duration values across all rows rather than counting them as two attendees. The Zoom adapter uses a merged dict keyed by email for exactly this.Overlapping sessions: union, not sum
Graph reports concurrent sessions. Take the union of a person’s time spans. Summing overlapping intervals overstates the time they were connected. See
_merged_minutes in the Microsoft adapter.No-shows at zero minutes
Dropping no-shows hides the most useful signal a meeting report has. Include participants who never connected with
minutes=Decimal("0") rather than omitting them.Skip participants with no address
Dial-in guests and unauthenticated participants cannot be costed. Omit them rather than inventing an identity. The
if not email or "@" not in email: continue pattern appears in every bundled collector.Testing an adapter
Test the mapping, not the network. Every bundled adapter separates its transport from a pure_record_from_* function precisely so the mapping can be tested against a captured payload literal — no network required.
The _record_from_* pattern
Pytest example
server/tests/fixtures/. A vendor’s documented schema and their actual API responses are not always the same thing, and a captured payload is the only reliable record of the difference.
Registering an adapter
1
Decorate the class
Use the appropriate decorator. The decorator registers the class in the in-memory registry under
cls.name:2
Import in adapters/init.py
The decorator only fires when the module is imported. Add an import line to
server/meetcost/adapters/__init__.py:3
Verify registration
Once the server is running, call the admin endpoint to confirm the adapter appears:The response lists every registered adapter by type and name, together with whether it is currently configured.
Contributing an adapter back
Adapters are the most useful contribution to this project. Open a pull request containing:- The adapter module in
server/meetcost/adapters/. - Tests in
server/tests/— with at least one captured payload fixture and tests that pass without a network connection. - A paragraph in
docs/ADAPTERS.mddescribing the vendor, the authentication method, and any non-obvious mapping decisions.