dodil/erp-method

modules/business-partner/module.yaml

# Business partner — the module source. See modules/crm/module.yaml for the format.

start_here: >-
  START HERE, before reading on: dodil://erp/build is the map (read it first; this spec is a vocabulary,
  not the method). Then, in the project folder, two commands: (1) `uvx --from git+https://git.dodil.io/dodil/erp-platform.git@v0.8.2 erp-compose init business-partner`;
  (2) SHOW the customer the diagram: `uvx --from git+https://git.dodil.io/dodil/erp-platform.git@v0.8.2 erp-compose bpmn --from ERP_BUSINESS_PARTNER.md
  --out processes/business-partner.bpmn` (before ERP_BUSINESS_PARTNER.md exists: `--from` this spec's
  module.yaml), then paste its step names in order, with who does each, into your message. The signed
  artifact is that BPMN: never prose, never "open bpmn.io". Cards: dodil://erp/phase/show, dodil://erp/phase/signoff.
# Version 4 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 3 stays the signed one until it is signed):
#   - seams only, no process change: GET /partners/{bp_id} listed for expenses (a re-billed customer) and pos (an
#     account customer at the till)
#
# Version 3 (2026-10-25, DRAFT for sign-off; the fixes the bricks and the end-to-end wiring found, and wave 3's routes):
#   - sales-order is listed as a caller of GET /partners/{bp_id} (SO-40 partner functions, the ship-to's country)
#   - GET /migration-map is guarded by bp:migration:read (machine, other modules' migration clients), the permission
#     gl, inventory and ar already name; until the code checks it, the row says so
#   - use cases (MCP tools) of the shared families name the module noun (tool_names)
#
id: business-partner
name: Customers & suppliers (shared)
short: Business partner
version: 4
maturity: deployed        # runs live; its own package had no schema.sql/migrate.py until the suite test needed one
summary: >-
  One record per company or person you do business with, shared by every other module — so
  sales, invoicing and purchasing all mean the same customer.

facets:
  domain: master-data
  industries: []          # cross-industry
  customer_words:
    - "one customer list"
    - "our customers and suppliers"
    - "the same client in sales and accounting"
  replaces: [the customer tab in every spreadsheet, duplicate customer lists across tools]
  pillars: [sql, graph]
  first_module_fit: foundation   # not a module anyone asks for; installed with the first module that needs a party
  depends_on: []
  feeds: [crm, ar, purchasing, gl, hr, contracts]
  owns_master_data: [business_partner, bp_role, bp_edges]
  references_master_data: []

actors:
  - id: data_steward
    name: Whoever keeps the customer list clean
    does: merges duplicates, fixes names, links subsidiaries to parents
    sees: every partner
    permissions: [bp:partner:write, bp:family:write]
  - id: any_module
    name: Other modules (sales, invoicing, purchasing)
    does: create-or-reuse a partner, read partners and families
    sees: every partner
    permissions: [bp:partner:read, bp:partner:promote, bp:migration:read]   # bp:migration:read: their migration clients only (machine)
  - id: people_module
    name: The people module (hr), through its service identity
    does: the SINGLE writer of person partners (name, work contact, roles employee / worker / supplier, the works_for edge), and anonymises them on erasure
    sees: person partners, and organisation partners as any module does
    permissions: [bp:person:sync, bp:person:read]
  - id: person_reader
    name: Roles that deal with people as parties (contracts, finance)
    does: reads person partners, for a contract counterparty or a supplier payment
    sees: person partners and organisation partners
    permissions: [bp:person:read]
  - id: system
    name: The system
    does: derives ids and enforces the sole-writer rule on every write
    sees: every partner, through its own service account
    permissions: []   # service account, not a pool user

stories:
  - id: BP-01
    kind: person
    actor: data_steward
    story: As the person who keeps the customer list, I want one record per real company however many modules use it, so that reports agree.
    given: sales and invoicing both refer to Acme
    when: I open Acme
    then: I see one partner with two roles (customer, and supplier if we also buy from them)
    reference: implemented
  - id: BP-02
    kind: system
    actor: system
    trigger: whenever any module creates a partner
    story: The system derives the partner id from a stable business key, so that importing the same company twice creates it once.
    given: a partner with key acme.com
    when: it is created twice, by two modules
    then: one row, the same id both times
    reference: implemented (bp_id = blake2b(bp_key), 56-bit)
  - id: BP-03
    kind: person
    actor: data_steward
    story: As the data steward, I want to link subsidiaries to their parent, so that group-level reporting is possible.
    given: Acme UK and Acme DE
    when: I link both to Acme Group as subsidiary_of
    then: the family walk returns both; a framework-agreement link does not leak into the ownership tree
    reference: implemented (typed edges)
  - id: BP-04
    kind: integration
    actor: any_module
    trigger: when sales converts a new company
    story: Sales promotes a company into the shared list instead of keeping its own.
    given: a CRM organization row
    when: promote is called
    then: a partner exists (created or reused) with the customer role
    owner: business-partner
    reference: implemented (POST /partners/promote)

  # person partners — specced 2026-10-17 with the hr module (a pilot engagement); not yet built
  - id: BP-05
    kind: integration
    actor: people_module
    trigger: when hr hires a person, changes their name or work contact, or a role-bearing event takes effect
    story: hr keeps the person's partner right, as its only writer, so that every module addresses a person with one key.
    given: a person in hr with person_id 812…, and no partner for them
    when: hr calls bp:person:sync with bp_key 'person:<person_id>', the legal and preferred name, the work email and phone, the roles and, for a company freelancer, works_for → the company's partner
    then: >-
      one partner of kind person exists (bp_id derived from the bp_key, so hr knows it without asking), with
      exactly those fields; a second identical call changes nothing; a payload carrying a personal email,
      phone, home address, tax or bank field is refused ("a person partner carries a name and a work contact
      only"); bp:partner:write cannot edit a person partner ("people are written by hr")
    owner: business-partner
    reference: not built
  - id: BP-06
    kind: person
    actor: any_module
    story: As anyone signed in without bp:person:read, I should not see people in the partner list, so that a starter's name is not visible to the whole company before they start.
    given: an organisation partner Acme and a person partner for a starter hired today
    when: I list partners or open the person partner by id
    then: I see Acme; the person partner is not in the list and opening it is not found (404, not 403); a caller with bp:person:read sees both
    screen: Partners
    reference: not built
  - id: BP-07
    kind: integration
    actor: people_module
    trigger: when hr applies an erasure or a retention rule to a person
    story: hr anonymises the person's partner, so that erasure reaches the shared record too.
    given: a person partner whose person is erased in hr
    when: hr calls bp:person:sync with anonymise=true
    then: the name and work contact are replaced with a fixed placeholder, the bp_id and roles history stay (other modules' records still join), and a second call changes nothing
    owner: business-partner
    reference: not built

workflows: []

entities:
  - id: business_partner
    meaning: a company or person you do business with
    natural_key: "bp_id derived from bp_key (a domain, a legacy customer number, a VAT id; for a person, 'person:<person_id>')"
    key_attributes: [bp_key, kind (organisation | person), legal_name, preferred_name, work_email, work_phone, country, tax_id]
    note: "kind person: written only by hr through bp:person:sync, name and work contact only, readable only with bp:person:read"
    master_data: SHARED — this module is the sole writer
    reference_table: business_partner
  - id: bp_role
    meaning: what the partner is to us — customer, supplier, carrier; and for a person, employee or worker
    natural_key: [bp_id, role]
    master_data: SHARED — sole writer
    reference_table: bp_role
  - id: bp_edge
    meaning: "a typed relationship between partners (subsidiary_of, framework_governs, works_for — a person to the company they freelance through)"
    natural_key: [src, dst, rel]
    master_data: SHARED — sole writer
    reference_table: bp_edges

master_data:
  owns: [business_partner, bp_role, bp_edges]
  references: []
  warning: >-
    DataK3 accepts foreign keys but does not enforce them. "Only this module writes partners" IS
    the integrity mechanism — other modules call its routes, never its tables.

jobs: []

seams:
  - story: BP-04
    with: crm
    owner: business-partner
    rule: other modules call POST /partners/promote or POST /partners
  - story: BP-05
    with: hr
    owner: business-partner
    rule: "hr calls bp:person:sync (POST /partners/persons/sync); it never writes business_partner, bp_role or bp_edges, and no other module may write a person partner"
  - story: BP-07
    with: hr
    owner: business-partner
    rule: hr calls bp:person:sync with anonymise=true
  # ── inbound: other bricks read the partner register (added 2026-10-23, seam-gap PR; read from brick-business-partner use_cases.get_partner / bp_migration_map) ──
  - story: BP-01
    with: ar
    owner: business-partner
    route: GET /partners/{bp_id}
    permission: bp:partner:read
    request: "path bp_id (BIGINT)"
    request_schema:
      - { field: bp_id, type: bp_id, required: true, in: path }
    response: "200 {bp_id, bp_key, legal_name, country, tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner (also a person the caller may not see)"
    rule: "ar reads only existence (200 = known, 404 = add the customer to the register first); it never writes a partner"
    status: "added 2026-10-23, seam-gap PR"
  - story: BP-01
    with: pos
    owner: business-partner
    route: GET /partners/{bp_id}
    permission: bp:partner:read
    request: "path bp_id (BIGINT)"
    request_schema:
      - { field: bp_id, type: bp_id, required: true, in: path }
    response: "200 {bp_id, bp_key, legal_name, country, tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner (also a person the caller may not see)"
    rule: "ar reads only existence (200 = known, 404 = add the customer to the register first); it never writes a partner"
    status: "business-partner v4 2026-10-26 (spec gaps wave 4): listed for pos POS-14 (an account customer at the till)"
  - story: BP-01
    with: expenses
    owner: business-partner
    route: GET /partners/{bp_id}
    permission: bp:partner:read
    request: "path bp_id (BIGINT)"
    request_schema:
      - { field: bp_id, type: bp_id, required: true, in: path }
    response: "200 {bp_id, bp_key, legal_name, country, tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner (also a person the caller may not see)"
    rule: "ar reads only existence (200 = known, 404 = add the customer to the register first); it never writes a partner"
    status: "business-partner v4 2026-10-26 (spec gaps wave 4): listed for expenses EXP-21 (a re-billed customer)"
  - story: BP-01
    with: payments
    owner: business-partner
    route: GET /partners/{bp_id}
    permission: bp:partner:read
    request: "path bp_id (BIGINT)"
    request_schema:
      - { field: bp_id, type: bp_id, required: true, in: path }
    response: "200 {bp_id, bp_key, legal_name, country, tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner"
    rule: "payments asks AS THE CALLER before it takes a receipt for a customer: 200 = known, 404 = refused with 'add the customer to the partner register first', 401/403 = the caller may not look that customer up"
    status: "added 2026-10-23, seam-gap PR"
  - story: BP-01
    with: tax
    owner: business-partner
    route: GET /partners/{bp_id}
    permission: bp:partner:read
    request: "path bp_id (BIGINT)"
    request_schema:
      - { field: bp_id, type: bp_id, required: true, in: path }
    response: "200 {bp_id, bp_key, legal_name, country, tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner"
    rule: "tax reads legal_name and address for the customer on a tax document; the register returns no `address` today, so tax prints an empty one (open question: an address on the partner view)"
    status: "added 2026-10-23, seam-gap PR"
  - story: BP-01
    with: purchasing
    owner: business-partner
    route: GET /partners/{bp_id}
    permission: bp:partner:read
    request: "path bp_id (BIGINT)"
    request_schema:
      - { field: bp_id, type: bp_id, required: true, in: path }
    response: "200 {bp_id, bp_key, legal_name, country, tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner"
    rule: "purchasing reads the supplier it is about to bill or pay (legal_name, can_transact); it creates suppliers only through POST /partners/promote"
    status: "added 2026-10-23, seam-gap PR"
  - story: BP-01
    with: sales-order
    owner: business-partner
    direction: inbound
    route: GET /partners/{bp_id}
    permission: bp:partner:read
    request: "path bp_id (BIGINT): each of sold-to, ship-to, bill-to and payer"
    request_schema:
      - { field: bp_id, type: bp_id, required: true, in: path }
    response: "200 {bp_id, bp_key, legal_name, country (ISO 3166-1 alpha-2), tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner"
    rule: "sales-order accepts a partner function only for an active partner with the role it plays (SO-40) and copies the ship-to's country as ship_to_country; the register keeps no postal address (sales-order copies or types the address on the order)"
    status: "added 2026-10-25 (v3): the caller side is sales-order v7 SO-40"
  - story: BP-02
    with: ar
    owner: business-partner
    route: "GET /migration-map/{db}/{model}?ids="
    permission: bp:migration:read        # v3: machine; enforced_today: none checked (any signed-in caller) until the code checks it
    request: "path db (the Odoo database), model res.partner; query ids=1,2,3 (comma-separated Odoo ids, at most 500)"
    request_schema:
      - { field: db, type: string, required: true, in: path }
      - { field: model, type: string, required: true, in: path, values: [res.partner] }
      - { field: ids, type: array, required: true, in: query, items: [integer] }
    response: "200 [{odoo_model, odoo_id, ours: bp_id as a string}]; an id with no mapping is omitted, never a 404"
    rule: "the one map route (shared.yaml conventions.odoo_migration.map_route); ar resolves each Odoo partner of an invoice to a bp_id through it, so business-partner's migration runs before ar's"
    status: "added 2026-10-23, seam-gap PR"
  - story: BP-02
    with: payments
    owner: business-partner
    route: "GET /migration-map/{db}/{model}?ids="
    permission: bp:migration:read        # v3: machine; enforced_today: none checked (any signed-in caller) until the code checks it
    request: "path db (the Odoo database), model res.partner; query ids=1,2,3 (comma-separated Odoo ids, at most 500)"
    request_schema:
      - { field: db, type: string, required: true, in: path }
      - { field: model, type: string, required: true, in: path, values: [res.partner] }
      - { field: ids, type: array, required: true, in: query, items: [integer] }
    response: "200 [{odoo_model, odoo_id, ours: bp_id as a string}]; an id with no mapping is omitted, never a 404"
    rule: "payments maps each Odoo receipt's partner to a bp_id through it"
    status: "added 2026-10-23, seam-gap PR"
  - story: BP-02
    with: tax
    owner: business-partner
    route: "GET /migration-map/{db}/{model}?ids="
    permission: bp:migration:read        # v3: machine; enforced_today: none checked (any signed-in caller) until the code checks it
    request: "path db (the Odoo database), model res.partner; query ids=<one Odoo id>"
    request_schema:
      - { field: db, type: string, required: true, in: path }
      - { field: model, type: string, required: true, in: path, values: [res.partner] }
      - { field: ids, type: array, required: true, in: query, items: [integer] }
    response: "200 [{odoo_model, odoo_id, ours: bp_id as a string}]; an id with no mapping is omitted, never a 404"
    rule: "tax resolves one Odoo partner id to a bp_id when it migrates a cash-basis or CIS row"
    status: "added 2026-10-23, seam-gap PR"

tool_names:                            # shared.yaml conventions.tool_names (2026-10-25): use-case (MCP tool) names unique across a composition; the route and permission are unchanged
  - { tool: get_partner_migration_map, route: "GET /migration-map/{db}/{model}", was: "bp_migration_map (unique today; renamed to the family's form at the next release)" }

screens:
  - { id: partners, name: Partners, stories: [BP-01, BP-06], actors: [data_steward, any_module],
      note: "the reference is API-only; a customer system usually reaches partners through the modules that use them" }
  - { id: partner,  name: Partner, stories: [BP-03], actors: [data_steward] }

permissions:
  namespace: bp           # short form; every permission below carries it
  catalog:
    - bp:partner:read
    - bp:partner:write
    - bp:partner:promote
    - bp:family:write
    - bp:person:sync
    - bp:person:read
    - bp:migration:read      # v3, machine: other modules' migrations read GET /migration-map/{db}/{model} (shared.yaml conventions.odoo_migration)
  machine:
    - bp:migration:read
  # WHAT THE CODE ENFORCES TODAY, which is not yet what the catalog above says. The catalog is
  # the target shape (<module>:<object>:<verb>, one namespace per module); these are the names
  # erp-business-partner actually checks. Configure a pool from the catalog alone and every
  # write answers 403 — so the true names are published here until the code is migrated and
  # the deployed role catalogs are updated with it.
  enforced_today:
    - erp:partners:write
    - "GET /migration-map: no permission checked yet (bp:migration:read is the target, v3)"
  row_visibility:
    rule: >-
      every signed-in user may read ORGANISATION partners; PERSON partners are readable only with
      bp:person:read (hr, contracts and finance roles) and written only by hr (bp:person:sync); the
      steward writes organisation partners
    enforced: "the write routes require bp:partner:write (organisations) or bp:person:sync (persons); every read filters person partners unless the caller holds bp:person:read; other modules call promote"
    not_found_not_forbidden: true

config_rows:
  - { table: bp_role, changes: "the roles a partner can have to you — customer, supplier, carrier, and any of your own (employee and worker are hr's, for person partners)" }
in_code_not_rows:
  - "that bp_id is derived from bp_key, so the same company imports once"
  - "that a family walk filters on the relationship type"
  - "that only this module writes partner tables"
  - "that a person partner is written only through bp:person:sync, carries a name and a work contact only, and is readable only with bp:person:read"

tests:
  acceptance: "one per story id above, named for it"
  access:
    - "a caller without bp:partner:write cannot create or edit a partner"
    - "a request with no gateway identity is rejected"
    - "a caller without bp:person:read gets 404 on a person partner and never sees one in a list; organisation partners are unaffected"
    - "bp:partner:write cannot create or change a person partner"
  invariants:
    - "creating the same partner twice yields one row and the same id"
    - "bp:person:sync twice with the same payload changes nothing; a payload with a private field is refused"
    - "a typed family walk never returns a partner linked by another relationship type"
    - "a partner cannot be its own parent"

scale:
  reads: "the tables reader serves 16 concurrent reads; the family walk is a recursive CTE — cache it per request, not per row"
  hot_paths: ["partner lookup by bp_key during import", "family walk for group rollups"]
  workers: none
  volumes: "hundreds of thousands of partners; the 56-bit derived id has ample headroom"

# What differs between customers — with something for them to react to, and where one exists,
# something they can look up.
#
# A business user can rarely recall a policy on demand and can always react to one. "Most firms
# like yours chase at 7, 14 and 30 days — does that match?" gets a truer answer, faster, than
# "when do you chase?". Read it out, let them correct it, write down what they said.
#
# Two kinds of answer, and they must never be confused:
#
#   standard:  a NAMED external authority the customer can look up and hold us to — a
#              directive, an accounting standard, a statute. Adopt it, or decide deliberately
#              to differ and know why. If a line here cannot be cited, it does not belong.
#   typical:   arrangements we have SEEN. No authority, no survey behind them, no implication
#              that a customer doing otherwise is doing it wrong. Conversation, not evidence.
#
# Calling convention a standard is how an implementation ends up defending a choice nobody
# ever made.
customer_varies:
  - question: What identifies a customer for you — a website, a customer number, a tax id?
    changes: bp_key
    typical:
      b2b_services: a customer number they already use in their old system
      vat_registered_trade: the tax id, because it is what the invoice must carry anyway
      warning: a web domain looks stable and is not — it moves on a rename or acquisition
  - question: Do you buy from any of the companies you sell to?
    changes: roles
  - question: Do you deal with groups of companies?
    changes: bp_edges

reference:
  # `name` is the brick's DISTRIBUTION name (pyproject [project].name): erp-compose init pins
  # `<name> @ git+<https>@<tag>`, and pip refuses a pin whose name differs from the package's.
  repos:
    - name: brick-business-partner
      role: brick
      https: https://git.dodil.io/dodil/erp-brick-business-partner.git
      ssh: git@git.dodil.io:dodil/erp-brick-business-partner.git
      tag: v0.4.0
      read_first: [README.md, AGENTS.md]
    - name: erp-platform
      role: platform
      https: https://git.dodil.io/dodil/erp-platform.git
      ssh: git@git.dodil.io:dodil/erp-platform.git
      tag: v0.9.13
      read_first: [README.md, AGENTS.md]
  packages: []
  contracts: []          # the skill-contract tier is retired; the spec IS the contract
  build_guide: null
  known_gaps:
    - "no merge-duplicates (a person asking to merge two partners cannot yet)"
    - "no addresses; contacts (people at a partner) are the CRM's, held by brick-activity, not here"
    - "no screen: API and MCP only"
    - "sync_person takes no version: hr is a person partner's single writer, and a sync that lost a race is re-run"
    - "invoicing's column named bp_id holds what this module calls a bp_KEY, not a bp_id, so the cross-module join needs a CAST. This module is right; the naming downstream is not - design/ar-bp-id-change-request.md"