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.
What is genuinely missing
Section titled “What is genuinely missing”- 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.
- There is no “common claim” project concept, and the notes’ per-project access rules and status vocabulary have no home.
- 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).
What this is not
Section titled “What this is not”- Not a
projecttable, and not aproject_idcolumn 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.
Decisions
Section titled “Decisions”- PU1 — A project stays an analytic account on the
projectaxis. The notes’ fields map as the table above shows; the field name every module shares is the axis keyproject. 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 theparent_idtree; “common claim” is not a type (PU6).status(draft/open/on hold/closed):is_activealready 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_memberis 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.NULLmeans 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),NULLis how “any” is written. Flow names areexpense_claim,purchase_invoice,sales_invoice,trade_document,fixed_assetandmanual_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 onanalytic_account, so cost centres can use it too. Rejected: the notes’project_module_settingrows. 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), besidePlan, not inside it.Planhas seven callers andledger.Postdoes not know its source; threading a source through it widens a signature for no gain. The check is called at the entry points, next toPlan: expensesline.go(line save) andsubmission.go(the R17 re-check, so a draft typed before the restriction is refused at submit with the line named). Refusal isErrNotChargeable, mapped bymapShareErrorlike the other share errors, naming the account and the flow.ledger.Postdoes 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-optionsreturns an account only ifchargeable_by IS NULL OR 'expense_claim' = ANY(chargeable_by). The claim wizard needs no change.AnalyticAccountgainschargeable_by(read, create, patch); setting it needsaccounting.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 oneINTparent, withchargeable_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 isis_requiredon 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_profilerow keyed(org_id, analytic_account_id)carriescustomer_id, dates andcreated_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
CheckChargeablewith its own flow name; no per-module column, table or branch.
Behaviour to state plainly
Section titled “Behaviour to state plainly”- Retiring a project (
is_active = false) already blocks new entries and the accrual posting of a claim approved before it was retired, becauseledger.Postre-runsPlan. Reactivating the project lets that claim through. Kept as is (open question 2). chargeable_byedits apply to future entries and submissions only.- A project with
chargeable_byset but noexpense_claimis 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 everyanalytic.Plan(call site underinternal/, asserts it found some, and fails if one has noCheckChargeablebeside it. The one exempt site isledger.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_bywhen 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.
Rollout
Section titled “Rollout”- Migration
01120(declared range01120–01121): the column and its CHECK. Down drops the column. No new table, so no RLS or retention change. api/openapi.yamlfirst:chargeable_byon theAnalyticAccountschema and on the create and patch requests; regenerate the Go server and the TS client.- Go:
AccountPatch/AccountInputgain the field (audited like name and active);CheckChargeableandCheckChargeableShares; wired inexpenses/line.goandexpenses/submission.go, payables, receivables, trade, assets, the manual-journal handler and banking’sPostFromLine;/expenses/analytic-optionsfiltered; the drift guard. - Web: a “Charged by” checklist in
ProjectDialog; strings in en, ms, zh.
Each absence-assertion establishes presence first (AGENTS.md):
CheckChargeable:NULLpasses; 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_claimis added to its list. analytic-optionsomits the restricted account; it was present before the restriction.ledger.Poststill accrues a claim approved before the restriction (watch it go red if the gate is moved intoPlan).- Web:
ProjectDialogwriteschargeable_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. |
Open questions
Section titled “Open questions”Each has a default; say if one is wrong.
- Mandatory on claims? Default: optional.
is_requiredis 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. - Retiring a project strands approved, unposted claims. Default: keep, reactivate to
proceed. Exempting in-flight claims means a flag through
ledger.Post. - On hold as a state distinct from retired. Default: no.
- Projects open only to invited members. Settled (2026-10-06): built.
analytic_account.members_only(default false, so every existing project is unchanged) plusproject_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 inledger.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. - Late claims on a closed project. Default: refused; finance reactivates it.
- Budget control, warn or block. Default: unchanged; it is a budget behavior, not a project one.
- Multi-company. Default: per organization (§17.1); a group sees projects through consolidation.
- Code format. Settled (2026-10-06): customizable. A project saved with a blank code is
numbered from the
projectnumbering scope (defaultPRJ-{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. - Custom ERP or Odoo. Settled: custom, this repo.