# Customers & suppliers (shared) — module spec (generated from modules/business-partner/module.yaml)
# A starting vocabulary for phase 2: correct it with the customer, then write ERP_BUSINESS_PARTNER.md.
schema: dodil.module-spec/v1
id: business-partner
name: Customers & suppliers (shared)
short: Business partner
version: 1
maturity: deployed
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: []
  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
  depends_on: []
  feeds:
    - crm
    - ar
    - purchasing
    - gl
  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
  - 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: []
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)
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)
    key_attributes:
      - bp_key
      - legal_name
      - country
      - tax_id
    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
    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)
    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
screens:
  - id: partners
    name: Partners
    stories:
      - BP-01
    actors:
      - data_steward
    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
  catalog:
    - bp:partner:read
    - bp:partner:write
    - bp:partner:promote
    - bp:family:write
  enforced_today:
    - erp:partners:write
  row_visibility:
    rule: every signed-in user of the system may read partners; only the steward writes them
    enforced: the write routes require bp:partner:write; 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
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
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
  invariants:
    - creating the same partner twice yields one row and the same id
    - 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
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:
  packages:
    - id: erp-business-partner
      kind: app
      what: partners, roles, typed family edges; FastAPI
      url: https://blog.dodil.io/code/erp-business-partner/
  contracts: []
  build_guide: null
  known_gaps:
    - no merge-duplicates route
    - no screen — API only
    - 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
    - promote reads CRM's organizations row when there is one; the module installs first, so send legal_name in the body on a bucket with no CRM
    - "no tests of its own: what is verified runs through the four-module suite in code/crm-suite-app/v1/tests/test_suite.py"
lifecycle: https://blog.dodil.io/modules/lifecycle.md
module_pattern: https://blog.dodil.io/modules/pattern.md
design_template: https://blog.dodil.io/modules/template.md
