Skip to content

Project usage across the ERP — design

Status: draft 2026-10-06, written from the owner’s discussion notes (“Project Usage in ERP”) and checked against the code. Awaiting owner answers to the open questions at the end. Roadmap: Phase 2, Project usage across modules. Built: migration 01120, analytic.CheckChargeable, the API, “Charged by” in the project dialog, and the gate in every flow that exists today (expense claims, purchase and sales invoices, trade documents, fixed assets, manual journals and bank-statement journals) with a drift guard. Not built: what Later and the open questions leave out.

Finding: most of the notes is already built

Section titled “Finding: most of the notes is already built”

The notes propose a shared project master that every module points at through an optional project_id. This ERP has that, under another name. A project is an analytic_account on the seeded project analytic axis (00050, analytic.step.go), and a line’s “project_id” is an entry in its shares.

The notes ask for What is already here
One master, referenced by every module analytic_account on the org’s project axis; shares JSONB on expense_claim_line, purchase_invoice_line, sales_invoice_line, trade_line, asset; analytic_distribution on every journal line (append-only)
Stable id, separate display code id (uuid), code unique per (org, axis), format-checked
Optional link, “required” through settings Shares are optional; analytic_axis.is_required is the setting, enforced by analytic.Plan
project_id on the line, split across projects Shares carry a percentage per account; each axis’s shares must total exactly 100 (R26)
Closed projects refuse new postings is_active = false: Plan returns ErrAccountInactive
One can_post function every module calls analytic.Plan: called by expenses (line save and submission), payables, receivables, trade, assets and ledger.Post
Never hard-delete No delete path: “retire and recreate” (analytic.go)
Hierarchy, internal vs customer grouping parent_id tree on the same axis
Manager approves claims for their project Approval by project (Phase 2, ticked): per-account, per-step approver bands
Budget vs actual per project Axis budgets and budget-vs-actual (00370); reports filter by analytic account
Claim form picker, claims list by project GET /expenses/analytic-options; ClaimWizard charge-to step; Projects column in the claims table
Project admin screen web/src/features/projects (create, rename, retire, approvers)

So this spec is a delta, not a new master. It is deliberately small.

  1. Nothing says which flows may charge a project. Every active project is chargeable by every module, so an office-misc project meant for claims can also be put on a sales invoice line.
  2. There is no “common claim” project concept, and the notes’ per-project access rules and status vocabulary have no home.
  3. Modules named in the notes that do not exist here yet: timesheet, fleet, manufacturing, helpdesk, subcontract. Petty cash and advances carry no shares (project-approval P12). Payroll has no org-unit → analytic mapping (PD-19).
  • Not a project table, and not a project_id column on any line. That would duplicate shares, Plan, the distribution trigger, approval by project and budgets.
  • Not a change to approval. Approval by project stands as built.
  • Not CRM, billable re-invoicing, timesheets or purchasing. The Later table says how each plugs in.
  • PU1 — A project stays an analytic account on the project axis. The notes’ fields map as the table above shows; the field name every module shares is the axis key project. Rejected: a new master table (breaks every existing share, distribution and approver row, for fields nothing reads yet).
  • PU2 — No new project attributes now. Each of the notes’ fields either exists or waits for its first consumer:
    • type (customer/internal/common): derived later from the customer link (PU7) and the parent_id tree; “common claim” is not a type (PU6).
    • status (draft/open/on hold/closed): is_active already is open/closed, with the effect the notes want. On hold has no consumer (open question 3).
    • manager_id: the approver cells are the only per-project people. project_member is built for visibility only (open question 4).
    • company_id: an organization is one legal entity (§17.1); created_from, budget, dates: no consumer.
  • PU3 — One addition: analytic_account.chargeable_by TEXT[] NULL. NULL means any flow, which is today’s behavior, so the migration changes nothing and needs no backfill. A non-null value is the set of flows allowed to charge the account; an empty array is refused by a CHECK (cardinality > 0), NULL is how “any” is written. Flow names are expense_claim, purchase_invoice, sales_invoice, trade_document, fixed_asset and manual_journal. The names are validated in Go against that list, not by a DB CHECK: a CHECK would be a migration per new module, which is what the notes said to avoid. It lives on analytic_account, so cost centres can use it too. Rejected: the notes’ project_module_setting rows. Same expressiveness at this size, but “no row = disabled” needs a backfill to avoid breaking every project today, and “no row = enabled” is a column wearing a table’s cost.
  • PU4 — The gate is analytic.CheckChargeable(ctx, tx, source, accountIDs), beside Plan, not inside it. Plan has seven callers and ledger.Post does not know its source; threading a source through it widens a signature for no gain. The check is called at the entry points, next to Plan: expenses line.go (line save) and submission.go (the R17 re-check, so a draft typed before the restriction is refused at submit with the line named). Refusal is ErrNotChargeable, mapped by mapShareError like the other share errors, naming the account and the flow. ledger.Post does not check it: narrowing a project’s flows means “no new charges”, and must never strand a claim already approved.
  • PU5 — The picker follows the gate. GET /expenses/analytic-options returns an account only if chargeable_by IS NULL OR 'expense_claim' = ANY(chargeable_by). The claim wizard needs no change. AnalyticAccount gains chargeable_by (read, create, patch); setting it needs accounting.account.manage, like retiring.
  • PU6 — Common claim projects are ordinary project accounts. INT-ADMIN, INT-TRAVEL, INT-WELFARE (the code format allows them), optionally under one INT parent, with chargeable_by = {expense_claim} and no customer link. An administrator creates them: the right list differs per organization, so provisioning (StepOrderAxes) keeps seeding axes only. Making a project mandatory on every claim is is_required on the axis, which today is axis-wide: it would also demand a project on purchase invoices, assets and manual journals (open question 1). There is no default-fallback account.
  • PU7 — The customer link and CRM come later and are additive. When CRM exists, a 1:1 project_profile row keyed (org_id, analytic_account_id) carries customer_id, dates and created_from. The link points CRM → project only; the project table never learns about opportunities. Nothing existing moves.
  • PU8 — Billable is a flag on the claim line, not the project. Phase 3, with re-invoicing.
  • PU9 — The gate stays one function. A future module charging projects calls CheckChargeable with its own flow name; no per-module column, table or branch.
  • Retiring a project (is_active = false) already blocks new entries and the accrual posting of a claim approved before it was retired, because ledger.Post re-runs Plan. Reactivating the project lets that claim through. Kept as is (open question 2).
  • chargeable_by edits apply to future entries and submissions only.
  • A project with chargeable_by set but no expense_claim is invisible to claimants and refused for claims.
  • Every flow that exists today checks the gate, at the call that plans a line’s shares: expense claims (line save and submission), purchase invoices, sales invoices, trade documents, fixed assets (registration), manual journals, and journals booked from a bank statement line (flow manual_journal). A drift test (chargeable_guard_test.go) walks every analytic.Plan( call site under internal/, asserts it found some, and fails if one has no CheckChargeable beside it. The one exempt site is ledger.Post, by PU4.
  • Not gated, by design: depreciation runs, disposals, recurring postings and every other accrual re-post shares an earlier, already accepted charge. Narrowing a project changes what can be newly charged to it, not what is already booked.
  • A flow reads chargeable_by when a line is saved, not when a draft is later issued: a draft typed before a restriction can still be issued. Claims are the exception because their submission re-checks (R17); other flows can get the same re-check when someone asks.
  1. Migration 01120 (declared range 01120–01121): the column and its CHECK. Down drops the column. No new table, so no RLS or retention change.
  2. api/openapi.yaml first: chargeable_by on the AnalyticAccount schema and on the create and patch requests; regenerate the Go server and the TS client.
  3. Go: AccountPatch/AccountInput gain the field (audited like name and active); CheckChargeable and CheckChargeableShares; wired in expenses/line.go and expenses/submission.go, payables, receivables, trade, assets, the manual-journal handler and banking’s PostFromLine; /expenses/analytic-options filtered; the drift guard.
  4. Web: a “Charged by” checklist in ProjectDialog; strings in en, ms, zh.

Each absence-assertion establishes presence first (AGENTS.md):

  • CheckChargeable: NULL passes; a listed flow passes; an unlisted flow is refused.
  • Claim line save and submission refuse a restricted project, and the same project is accepted once expense_claim is added to its list.
  • analytic-options omits the restricted account; it was present before the restriction.
  • ledger.Post still accrues a claim approved before the restriction (watch it go red if the gate is moved into Plan).
  • Web: ProjectDialog writes chargeable_by; vitest and typecheck.
Notes’ phase How it plugs in
2 Purchase, Timesheet, accounting reports, budgeting Purchase, sales, trade, assets and journals are wired, with the drift guard. Timesheet does not exist: its module calls the same gate with its own flow name and adds it to the list in chargeable.go. Payroll waits on PD-19.
3 CRM, Sales, billable project_profile (PU7); opportunity points at the project; billable on the claim line (PU8).
4 Inventory, Fleet, Manufacturing, Helpdesk None exist in the roadmap. Each calls the gate with its own flow name when built.

Each has a default; say if one is wrong.

  1. Mandatory on claims? Default: optional. is_required is axis-wide, so turning it on also makes the project mandatory on invoices, assets and journals. A per-flow “required” would be a second column; not built unless asked.
  2. Retiring a project strands approved, unposted claims. Default: keep, reactivate to proceed. Exempting in-flight claims means a flag through ledger.Post.
  3. On hold as a state distinct from retired. Default: no.
  4. Projects open only to invited members. Settled (2026-10-06): built. analytic_account.members_only (default false, so every existing project is unchanged) plus project_member (account_id, member_id) (01121). A members-only project is offered to, and chargeable by, only its members, at expense-claim line save and submission (analytic.CheckMember), never in ledger.Post, so removing someone never strands an approved claim. Finance entering invoices, journals and assets is not limited by membership (they post for any project). Scale: members are invited up to 500 per call in one statement; the list pages by member id (keyset); the claim form reads “my projects” from an index-only (org_id, member_id, account_id) lookup, not by organization size. Not built: inviting a whole department or role at once (the next lever for very large organizations), and hiding closed projects from the invoice, journal and asset pickers.
  5. Late claims on a closed project. Default: refused; finance reactivates it.
  6. Budget control, warn or block. Default: unchanged; it is a budget behavior, not a project one.
  7. Multi-company. Default: per organization (§17.1); a group sees projects through consolidation.
  8. Code format. Settled (2026-10-06): customizable. A project saved with a blank code is numbered from the project numbering scope (default PRJ-{YYYY}-{SEQ:4}), set by the organization on the Code customization page, the renamed Document numbering page. A typed code still works and is skipped over by the counter. Codes stay fixed once created.
  9. Custom ERP or Odoo. Settled: custom, this repo.