Files
erpnext/specs/accounts_refactor_spec.md
Nabin Hait 234c4a45b8 refactor: extract list-level GL validations into gl_validator service
Phase 1 of the accounts/controller refactor. Moves the six pure
list-level validators (validate_disabled_accounts, validate_accounting_period,
validate_cwip_accounts, check_freezing_date, validate_against_pcv,
validate_allowed_dimensions) out of general_ledger.py into the new
erpnext/accounts/services/gl_validator.py. general_ledger.py imports and
calls them at the existing sites; no behavior change (Phase 0 GL snapshots
remain byte-identical).

The debit/credit balance trio stays in general_ledger.py for now since
get_debit_credit_difference mutates entries and is interleaved with the
round-off repair.
2026-05-27 01:01:20 +05:30

101 lines
8.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Accounts / Controller Refactor — Spec
## Motivation
Move ERPNext away from the deep `AccountsController → SellingController/BuyingController → SalesInvoice`
inheritance chain and the monolithic `sales_invoice.py` / god-object `accounts_controller.py`
toward **composition**: per-doctype `services/` plus shared module-level `accounts/services/`.
Goal is testability, readability, and factoring shared domain logic out so Sales/Purchase
voucher logic is not duplicated.
## Target structure
```
erpnext
├── controllers
│ └── transaction_controller.py # thin lifecycle base, delegates to services
├── accounts
│ ├── general_ledger.py # the SINK (unchanged): post / merge / round-off / reverse
│ ├── services
│ │ ├── base_gl_composer.py # BaseGLComposer — shared GL helpers
│ │ ├── gl_validator.py # list-level validation (functions, stateless)
│ │ ├── advances.py
│ │ ├── taxes.py
│ │ └── budget.py
│ └── doctype
│ └── sales_invoice
│ ├── sales_invoice.py # thin: delegates to services
│ ├── services
│ │ ├── gl_composer.py # SalesInvoiceGLComposer(BaseGLComposer)
│ │ ├── pos.py
│ │ ├── loyalty.py
│ │ ├── status.py
│ │ ├── inter_company.py
│ │ ├── fixed_assets.py
│ │ └── timesheet_billing.py
│ ├── mapper.py
│ └── api.py
```
## GL layer — frozen design
Pipeline:
```
SalesInvoiceGLComposer.compose() → gl_entries → gl_validator.validate(gl_entries) → general_ledger.make_gl_entries()
```
| Role | Location | Form | Responsibility |
|---|---|---|---|
| **Composer (base)** | `accounts/services/base_gl_composer.py``BaseGLComposer` | class (stateful, holds `self.doc`) | shared row factory + common entries |
| **Composer (doctype)** | `sales_invoice/services/gl_composer.py``SalesInvoiceGLComposer(BaseGLComposer)` | class | voucher-specific rows via `.compose()` |
| **Validator** | `accounts/services/gl_validator.py` | module functions (stateless) | assert the finished `gl_entries` list is legal to post |
| **Sink** | `accounts/general_ledger.py` (unchanged) | module functions | merge / round-off / post / reverse |
### Naming decisions (frozen)
- Chose **`compose`** over `make`/`build` — the sink already owns the verb `make` (`make_gl_entries`); `compose` avoids a two-makers collision.
- `base_` prefix on the shared/abstract file; the concrete subclass carries the specific name, no prefix.
- Rejected: `gl_map` (it's a list, not a map — but it's an entrenched public param; rename to `gl_entries` later as its own deprecation pass), `gl_processor` (redundant with `general_ledger.py`), `gl_entries.py` (collides with the `gl_entry` doctype + the ubiquitous local var), `ledger_builder` (clashes with stock/payment ledger), `builder`/`maker` (generic; "maker" collides with `make_gl_entries`).
## Bucketing `accounts_controller.py`
- **Base composer (`BaseGLComposer`):** `get_gl_dict`, `get_value_in_transaction_currency`, `make_discount_gl_entries` (+ `get_amount_and_base_amount`, `get_tax_amounts`), `make_precision_loss_gl_entry`, `make_exchange_gain_loss_journal` (+ `gain_loss_journal_already_booked`), `set_transaction_currency_and_rate_in_gl_map`. Regional hooks `update_gl_dict_with_regional_fields` / `..._app_based_fields` stay free functions called inside `get_gl_dict`.
- **Advances service:** `set_advances`, `get_advance_entries`, `clear_unallocated_advances`, `validate_advance_entries`, `set_advance_gain_or_loss`, `calculate_total_advance_from_ledger`, `set_total_advance_paid`, `set_advance_payment_status`, `delink_advance_entries`, `create_advance_and_reconcile`, `get_advance_payment_doctypes`, `_remove_advance_payment_ledger_entries`, module funcs `get_advance_journal_entries` / `get_advance_payment_entries`.
- **Validator (from `general_ledger.py`):** `validate_disabled_accounts`, `validate_accounting_period`, `validate_cwip_accounts`, `check_freezing_date`, `validate_against_pcv`, `validate_allowed_dimensions`. (Moved in Phase 1.)
- **Balance trio stays in `general_ledger.py` for now** (revised during Phase 1): `get_debit_credit_difference` / `get_debit_credit_allowance` / `raise_debit_credit_not_equal_error`. `get_debit_credit_difference` *mutates* entries (rounds debit/credit in place) and the trio is interleaved with `process_debit_credit_difference``make_round_off_gle` (the round-off *repair* run before and after balancing). It is not a standalone pre-post gate, so it can't move into a pure `validate(gl_entries)` without changing behavior. It travels with round-off when that moves compose-side (see below).
- **Stays in compose (do NOT move to validator):** `process_debit_credit_difference` / `make_round_off_gle` — these *repair* balance by appending a round-off entry (mutation), not validation.
- **Stays in composer (not validator):** row-level checks (right account for a row, dimension applicability) — validator only validates the finished list.
- **Leave in controller:** `validate_company_in_accounting_dimension`, `validate_company` (dimension validation, not GL).
## Phases
Each phase is behavior-preserving, one draft PR, gated by the Phase-0 snapshot suite + `bench run-tests --site test-site-ai`.
### Phase 0 — Safety net (first, mandatory)
Characterization tests snapshotting `gl_entries` output for representative transactions (SI/PI with taxes, multi-currency, advances, discounts, round-off, POS). Every later phase passes iff snapshots are byte-identical.
### Phase 1 — Extract `gl_validator.py` (lowest risk) — DONE
Moved the 6 pure list-level validators to `erpnext/accounts/services/gl_validator.py`; `general_ledger.py` imports and calls them at the existing call sites (no behavior change). A consolidated `gl_validator.validate(gl_entries)` facade is deferred — the current checks run at different points (make_gl_entries / save_entries per-entry / make_reverse_gl_entries), so collapsing them into one call would alter ordering. Verified: all 12 Phase-0 snapshots byte-identical.
### Phase 2 — Pilot composer on Sales Invoice only
Create `BaseGLComposer` + `SalesInvoiceGLComposer`; lift bucket-A helpers from `accounts_controller`; move SI's `get_gl_entries` body into `.compose()`; old method becomes a thin shim. Do not over-generalise the base from one example.
### Phase 3 — Second doctype: Purchase Invoice (base earns its shape)
Add `PurchaseInvoiceGLComposer`; reshape `BaseGLComposer` from what SI + PI *actually* share. Two real consumers is the minimum to size the abstraction — prevents premature abstraction.
### Phase 4 — Roll out composer to remaining GL-posting doctypes
Payment Entry, Journal Entry, Delivery Note, Stock Entry, etc. Mechanical now; one PR per doctype (or small batches), each snapshot-gated.
### Phase 5 — Extract `advances.py`
Move the advances cluster. After composers, because advances cross-calls the exchange-gain/loss helper now on `BaseGLComposer`.
### Phase 6 — Extract remaining domain services from `accounts_controller`
`taxes.py`, `budget.py`, etc. Shrink `accounts_controller` to a thin lifecycle base that delegates.
### Phase 7 — Split the rest of the `sales_invoice.py` monolith
Non-GL doctype services: `pos.py`, `loyalty.py`, `status.py`, `inter_company.py`, `fixed_assets.py`, `timesheet_billing.py`. Independent of GL work; can run parallel to 56.
### Phase 8 — Collapse the inheritance chain
Flatten `SellingController` / `BuyingController` layers that are now pass-through. Last, because only safe once the delegated-to services exist.
**Dependencies:** 1→2→3 sequential; 4 and 7 can parallelize once 3 lands; 8 always last.
## Cross-cutting rules
- Public signatures stay stable — keep the `gl_map=` param and `make_gl_entries` intact. The `gl_map → gl_entries` rename is its own deprecation pass, deferred to the end (or excluded).
- Composers are classes (stateful, per-document); sink and validator are stateless module functions.
- Every phase: behavior-preserving, snapshot + `bench run-tests --site test-site-ai` green before merge, draft PR.