dodil/erp-brick-business-partner

README.md

brick-business-partner

The shared party master: one record per company or person you do business with, so sales, invoicing and purchasing all mean the same customer. A brick on erp-platform; spec modules/business-partner in the blog repo. Every action is both an API route and an MCP tool.

What it does

StoryHow
BP-01 one record however many modules use ita partner has roles — customer, supplier, carrier, your own — so the company you sell to and buy from is one partner
BP-02 importing the same company twice creates it oncebp_id is derived from your business key (bp_key: a customer number, tax id or domain), normalised, so a re-import lands on the same row
BP-03 subsidiaries linked to their parenttyped edges (subsidiary_of, branch_of, framework_governs); the family walk follows one type only, so a framework agreement never leaks into an ownership tree. A company has one parent of each kind; cycles are refused
BP-04 other modules join the shared listpromote_partner creates-or-reuses and adds a role; modules call it and never write these tables
BP-05 hr keeps a person's partner right, as its only writersync_person (POST /partners/persons/sync, bp:person:sync) with the person card {person_id, legal_name, preferred_name, work_email, work_phone, country, roles, works_for}: a partner of kind person, bp_key person:<person_id> (so hr knows the bp_id without asking), roles exactly employee / worker / supplier as given, and a works_for edge to the company a freelancer works through. A second identical call changes nothing. A private field (personal email or phone, home address, tax, bank, NI…) is refused — "a person partner carries a name and a work contact only" — and any other unknown field is refused by name, never dropped
BP-06 people are not visible to the whole companywithout bp:person:read a person partner is absent from every list and family walk, and opening one is 404, never 403; with it, both kinds are listed. Organisations keep today's rule: every signed-in user reads them
BP-07 erasure reaches the shared recordsync_person(..., anonymise=true): the name and work contact become Erased person / empty; the bp_id, roles and links stay so other modules' records still join; a second call changes nothing, and a later sync of the same person is refused (a new engagement is a new person_id). The history records field NAMES only for persons, so it holds nothing an erasure would have to find

A partner can be blocked (no new business — can_transact is false) or archived, always with a reason.

0.3.2 — runs on erp-platform 0.8.1 to 0.9.x (2026-10-18)

The platform range widens to <0.10, so bricks generated by erp-compose scaffold (erp-platform 0.9) can be composed with business partners. No behaviour change.

0.3.1 — on erp-platform 0.8.1 (2026-10-18)

The erp-platform range moves to >=0.8.1,<0.9 so a composition with erp-brick-hr 0.5.0 resolves; no code change. Repo dodil/erp-brick-business-partner (renamed from dodil/brick-business-partner; the old URL still resolves).

0.3.0 — the CRM audit's wave 1 (2026-10-17)

FindingNow
C3 two stewards at once: one silently undid the othera partner carries a version (GET returns it, and sends it as the ETag). upsert_partner, set_partner_status and remove_role take version — or an If-Match: "3" header — and answer 409 "changed since you opened it" when it is stale. Without one, a change that lost a race is re-run on the latest partner, so both land
C4 bp_id past 2^53 rounded to another customer in JavaScript and in agentsevery id leaves as a string of digits ("45665172796086509"), through both doors; it comes in as digits or a number. OpenAPI and the MCP schema say type: string, format: int64
H12 a NUL in a name, a 5 000-character name or a 23-digit id was a 500bounded inputs (erp-platform 0.5 types): 422 naming the field. Names ≤ 200, keys ≤ 200, country 2, domain ≤ 253
H14 a rep could make any company a supplierwith bp:partner:promote alone, promote_partner gives the customer role only; any other role needs bp:partner:write (seemo, 2026-10-17)

Fixed against the reference package (blogs/code/erp-business-partner/v1)

  • A re-import reset status to active, unblocking a blocked customer. Status now changes only through set_partner_status.
  • A re-import without a tax id blanked the one on file. Fields left out are left as they are.
  • bp_key was stored in whatever case it arrived in while the id was derived lower-case.
  • A 56-bit id collision between two different keys would have overwritten the other partner. Refused.
  • Only self-parenting was checked; A → B → A and two parent companies are now refused.
  • promote read CRM's own table. It takes the details in the call instead.
  • created_at was rewritten on every save (DataK3 tables#417; the platform repository works around it).

The id derivation is unchanged (blake2b, 56 bits), so ids already in a bucket stay valid.

Who

Every signed-in user may read organisation partners. Changing them needs bp:partner:write (the data steward); family links need bp:family:write; other modules need bp:partner:promote — which gives the customer role only; promoting to any other role needs bp:partner:write too. No identity → 401.

Person partners: written only with bp:person:sync (hr's service identity), read only with bp:person:read (hr, contracts, finance). No steward or module permission writes one — "people are written by hr" (409 to a caller who may see people, 404 to one who may not, so a write never confirms a person exists).

Tables

business_partner, bp_role (the Partner aggregate), bp_edges (the family, and works_for), bp_events (history). The names are the spec's and the deployed package's. bp_id is BIGINT everywhere (modules/shared.yaml).

0.2.0 migration (additive): business_partner gains kind, preferred_name, work_email, work_phone, anonymised_at — at the end of its CREATE and as explicit ALTERs. A row from before has kind NULL and reads as an organisation; an organisation's save never names the new columns. erp-migrate marks the ALTERs DISRUPTIVE (reads on the table stall for minutes after an ADD COLUMN): migrate the test copy first, and the live one at a quiet hour, before rolling the 0.2.0 image (0.2.0 reads the new columns).

0.3.0 migration (additive): business_partner.version (INTEGER, NULL reads as 0) at the end of the CREATE and as an ALTER — DISRUPTIVE like 0.2.0's — and the claim table business_partner_versions. Migrate before rolling 0.3.0.

Run

pip install -e '.[test]'                    # needs erp-platform installed
pytest -q                                   # rules + both doors on SQLite
BUCKET=<scratch> DODIL_ORG=<org> python scripts/live_check.py   # the stories on DataK3

Reading ids through the CLI or an agent's data_pg: CLIs before 0.1.80 passed BIGINT through a float in JSON output, so ids above 2^53 — nearly every bp_id — came back wrong (fixed in 0.1.80). On an older CLI, read them with CAST(bp_id AS VARCHAR). The app's own driver was never affected.

Not in 0.3

Merging duplicates; contacts (people at a partner — the CRM's); addresses; a screen. Person partners carry the version too, but sync_person takes none: hr is their single writer, and a sync that lost a race is re-run.

License: Apache-2.0, see LICENSE