dodil/erp-method

node_modules/.cache/erp-method/v0.1.15/modules/shared.yaml

# The vocabulary that crosses module boundaries.
#
# A module can be as opinionated as it likes INSIDE its own prefix. This file is the part it
# does not get to decide alone, because a second module has to agree: the keys that join two
# modules, the tables everyone shares, and how money is carried.
#
# It exists because the four-module suite run on 2026-09-18 found the same bug twice in a
# different costume — one name, two meanings, discovered at runtime:
#
#   * `bp_id` is a BIGINT in the party master and a VARCHAR in invoicing, so the join the
#     party master exists to provide fails outright rather than returning wrong rows;
#   * `discount_pct` is a FRACTION in the CRM and a PERCENTAGE in invoicing, so a 10% discount
#     handed across the seam bills as 0.10% and nothing errors.
#
# Neither is exotic. Both are what happens when two modules are built a month apart and each
# one is right on its own. `npm run check:packages` reads this file and says so before a
# customer does.

# ── keys that appear in more than one module ───────────────────────────────────────────────
# One type, everywhere. Changing one of these after a module has stored it is a data
# migration, not a rename — which is why they are declared before the second module lands.
keys:
  bp_id:
    type: BIGINT
    owner: business-partner
    not_to_be_confused_with: >-
      bp_key — the customer's OWN identifier for a party (a domain, a legacy customer number,
      a tax id), which is a VARCHAR and is not this. Invoicing named a bp_key column "bp_id"
      and the two meanings ended up in one column, told apart only by whether they parse as a
      number. A module that accepts an arbitrary customer key is storing a bp_key; say so.
    why: >-
      the party id is walked as a graph node, and graph_khop() takes an integer start node, so
      the integer is the constraint and every other module carries it as one.
  account_id:
    type: BIGINT
    owner: gl
    why: the chart of accounts is walked for rollups, same reason as bp_id
  journal_id:
    type: BIGINT
    owner: gl
    why: derived by the caller from the source document, so a retry names the same journal
  tax_code:
    type: VARCHAR
    owner: gl
    why: >-
      a jurisdiction's code (S20, RC, Z) is a string the customer recognises; invoicing owns
      the rates only until a ledger is installed, and then the ledger owns them.
  invoice_id:
    type: VARCHAR
    owner: ar
    why: derived from the source document, and a customer's own numbering is not always numeric
  person_id:
    type: BIGINT
    owner: hr
    not_to_be_confused_with: >-
      person_key, the VARCHAR it is derived from ('staff:<no>', else 'email:<normalised personal
      email>'), and fixed once set. NOT the work email, which does not exist until the account is
      provisioned. NOT the pool user's sub, which hr_person.pool_user_sub maps to it once the person is
      invited to sign in. NOT bp_id: every person also has a business partner (bp_key 'person:<person_id>'),
      and a module dealing with them as a PARTY (a contract counterparty, a supplier) stores the bp_id.
      A column that holds a person under another name (manager_override_person_id, an owner) is still
      this type.
    why: >-
      derived like bp_id (int.from_bytes(blake2b(person_key, digest_size=7))), so a double-submitted
      hire or a re-import names the same person, and an integer, because a person is a graph node
      (person, position, org unit) and graph_khop() takes an integer start node. Not employee_id: a
      person may be a freelancer, and the same person may come back as either.
  contract_id:
    type: VARCHAR
    owner: contracts
    why: >-
      derived by the caller from the work that asks for the contract (hr: the run step), so a retry
      names the same contract; hr stores it on the run step that produced it and on the person's
      filed document
  cost_centre:
    type: VARCHAR
    owner: gl
    why: >-
      finance's code, which a person recognises. hr (assignments, positions, org units) and
      purchasing (requests) store it as a reference. Until gl carries cost centres (its known gaps
      list dimensions), it is a code validated against nothing, and still a VARCHAR, so the join exists the day it does.

# ── units, where the name does not carry them ──────────────────────────────────────────────
# A column called `discount_pct` reads as a percentage to everyone except the module that
# wrote it as a fraction. Say which, once.
units:
  discount_pct: percent          # 18 means 18% — a module storing 0.18 must convert at its seam
  tax_rate: percent

# ── money ──────────────────────────────────────────────────────────────────────────────────
money:
  sql: NUMERIC(18, 2)
  json: string                   # exact on the wire; a float re-introduces the drift we avoid
  columns_matching: "amount|total|price|balance|debit|credit|subtotal|cost"
  # a name can contain a money word and hold nothing of the kind: cost_centre is a label,
  # price_book_id is an identifier. Suffixes are how they are told apart.
  not_money_suffixes: ["_id", "_no", "_code", "_key", "_book", "_pct", "_centre", "_center", "_mode", "_ref", "_by", "_on", "_at"]   # _on/_at: dates (priced_on)
  also_not_money: ["normal_balance"]
  # ONE exception, and it is precise. A RATE or a per-unit amount, which is multiplied before anything
  # is paid (an hourly or day rate, a salary per basis, a pay component per unit), is NUMERIC(19, 4):
  # 13.3333 an hour is a real rate, and two decimals would bill a 37.5-hour week 0.12 short. What is
  # PAID or POSTED (a payable line, an invoice total, a journal amount) is still NUMERIC(18, 2), rounded
  # ONCE, at that line, to the currency's minor unit. Name rate columns rate_amount and paid ones
  # *_amount / total, so a reader can tell them apart. On the wire both are strings.
  # (check:packages enforces NUMERIC for every money column; it does not check the precision.)
  rates:
    sql: NUMERIC(19, 4)
    columns: [rate_amount]
    payable: NUMERIC(18, 2)        # e.g. hr_payroll_line.payable_amount: rate × quantity, rounded once

# ── table ownership ────────────────────────────────────────────────────────────────────────
# The naming IS the contract: a prefix says "this module owns it", no prefix says "this is
# shared master data and exactly one module writes it". A module-owned table sitting
# unprefixed is a collision waiting for the module that has not been written yet.
prefixes:
  business-partner: ""           # owns the shared party master; see shared_tables
  crm: crm_
  ar: ar_
  gl: gl_
  purchasing: pur_               # the sixth module, prefixed from its first table
  hire: hire_                    # declared before the module is built — that is the point
  hr: hr_                        # people, starters & leavers; declared with its spec (2026-10-17)
  contracts: ct_                 # contracts & e-signature; short, so the root table is ct_contract, not contract_contracts

# Table prefixes per BRICK REPO (reference.repos). A brick repo is one module's code split into
# bricks, each owning its own prefix, so the module prefix above does not apply to it. A repo not
# listed here skips the prefix rule. `business_partner` is the one shared table a prefix cannot name.
repo_prefixes:
  brick-business-partner: [bp_, business_partner]
  brick-activity: [activity_]
  brick-pipeline: [pipeline_]
  brick-quote: [quote_]
  brick-lead: [lead_]
  brick-hr: [hr_]
  brick-contracts: [ct_, contract_capability_]

# Each brick repo's entry in a compose.yaml: the member name and the Brick object
# erp_platform.compose loads ("package.module:OBJECT"). `erp-compose init` writes compose.yaml
# from these, so the format is never retyped: in the lab (2026-10-17) a model that found and
# cloned the right bricks invented its own compose.yaml format and the composition never loaded.
# scripts/check.py: every brick repo in any module's reference.repos has an entry here.
brick_entries:
  brick-business-partner: { name: bp,       brick: "brick_bp.application.use_cases:BP" }
  brick-activity:         { name: activity, brick: "brick_activity.application.use_cases:ACT" }
  brick-pipeline:         { name: pipeline, brick: "brick_pipeline.application.use_cases:PIPE" }
  brick-quote:            { name: quote,    brick: "brick_quote.application.use_cases:QUOTE" }
  brick-lead:             { name: lead,     brick: "brick_lead.application.use_cases:LEAD" }
  brick-hr:               { name: hr,       brick: "brick_hr.application.use_cases:HR" }
  brick-contracts:        { name: contract, brick: "brick_contracts.application.use_cases:CT" }

shared_tables:
  - business_partner             # written only by business-partner
  - bp_role
  - bp_edges
  - tax_code                     # invoicing seeds it; the ledger owns it once installed
  - platform_meta                # what this bucket IS — written once by migrate.py --stamp,
                                 # read by db.py before the first query. Platform layer, not
                                 # a module's: every app in the bucket reads the same row.