---
name: Default Accounts — Backend Flow
description: How GL "default account" settings are defined, seeded, stored, read at journal-posting time, and how the /setup page persists them. Cross-module (Core + Accounting) plumbing that every module's *_account_id setting rides on.
updated: 2026-06-18
---

# Default Accounts — Backend Flow

This topic documents the **shared backend plumbing** behind every "default account"
setting in Moon ERP. A *default account* = a `Setting` whose stored value is a GL
`Account` id (keys usually end in `_account_id`) that a posting Action reads
automatically when it builds a JournalEntry. The per-module catalogs of those keys
(LIS / Production / HR / Sales / Purchases / POS) live in the sibling topic files in
this folder; **this file is the engine they all share**.

Two repos:
- BACKEND Laravel: `/home/moonui/moon-erp-be` (modules under `Modules/<Name>/`)
- FRONTEND Angular: `/home/moonui/public_html/moon-erp` (the `/setup` page)

---

## 1. The four layers

```
SettingDefinition (catalog row)  ──seeded by──►  SettingDefinitionSeeder
        │  setting_key, value_type=integer, default_value=null, display_group
        ▼
Setting (per-company stored value) ──written by──►  SettingsService::set()  ◄── PUT /api/core/settings  ◄── /setup page
        │  company_id, setting_key, value="74", branch_id, user_id
        ▼
SettingsService::get('lis.receivable_account_id', $companyId)  ──returns──►  74 (int)
        ▼
Posting Action (e.g. PostLabInvoice)  ──►  JournalEntryLine['account_id' => (int) 74]
```

The **value** column is always stored as a string (`(string) $accountId`), and read
back cast to `integer` because the definition's `value_type` is `integer`.

---

## 2. Where definitions are seeded

### 2.1 Core seeder (the master catalog)
`Modules/Core/database/seeders/SettingDefinitionSeeder.php` (2165 lines)

- `run()` @ **line 10** → loops `getDefinitions()` (@ **line 25**) and
  `SettingDefinition::updateOrCreate(['setting_key' => …], $definition)` @ **line 15**.
  Idempotent on `setting_key` — re-running only updates labels/metadata, never the
  per-company stored value.
- Each `*_account_id` definition is `value_type => 'integer'`, `default_value => null`,
  `scope => 'company'`, grouped by `display_group` (e.g. `sales_accounting`,
  `purchases_accounting`, `hrm_accounting`, `lis_accounting`).

Account-id definitions present in the Core seeder (file:line of the `'setting_key' =>`):

| Module group | Key | Line |
|---|---|---|
| accounting | `accounting.checks_issued_account_id` | 309 |
| accounting | `accounting.checks_received_account_id` | 325 |
| accounting | `accounting.zakat.expense_account_id` | 341 |
| accounting | `accounting.zakat.payable_account_id` | 357 |
| accounting | `accounting.wht_payable_account_id` | 373 |
| sales | `sales.revenue_account_id` | 663 |
| sales | `sales.cogs_account_id` | 679 |
| sales | `sales.receivable_account_id` | 695 |
| sales | `sales.inventory_account_id` | 711 |
| sales | `sales.tax_payable_account_id` | 743 |
| sales | `sales.discount_account_id` | 759 |
| purchases | `purchases.inventory_account_id` | 1084 |
| purchases | `purchases.expense_account_id` | 1100 |
| purchases | `purchases.payable_account_id` | 1116 |
| purchases | `purchases.tax_receivable_account_id` | 1132 |
| purchases | `purchases.discount_account_id` | 1148 |
| hrm | `hrm.salary_expense_account_id` | 1443 |
| hrm | `hrm.salary_payable_account_id` | 1459 |
| hrm | `hrm.social_insurance_account_id` | 1475 |
| hrm | `hrm.medical_insurance_account_id` | 1491 |
| hrm | `hrm.income_tax_account_id` | 1507 |
| hrm | `hrm.loan_receivable_account_id` | 1523 |
| hrm | `hrm.eos_provision_account_id` | 1539 |
| hrm | `hrm.eos_expense_account_id` | 1555 |
| hrm | `hrm.overtime_expense_account_id` | 1711 |
| hrm | `hrm.leave_accrual_account_id` | 1712 |
| lis | `lis.receivable_account_id` | 1716 |
| lis | `lis.revenue_account_id` | 1732 |
| lis | `lis.insurance_receivable_account_id` | 1748 |
| lis | `lis.tax_payable_account_id` | 1764 |
| pos | `pos.cash_receiving_account_id` | 1974 |
| pos | `pos.card_receiving_account_id` | 1990 |
| pos | `pos.default_receiving_account_id` | 2006 |

> Module-specific keys not in the list above (the rest of `lis.*`,
> `production.*`, additional `external_lab_*`, doctor-commission accounts, etc.)
> are catalogued in the sibling topic files — some are seeded by the **LIS module's
> own** `Modules/LIS/database/seeders/LabSettingDefinitionSeeder.php`, and several
> `production.*` keys are **lazily created at posting time** (never seeded as a
> definition — see §6).

### 2.2 LIS module seeder
`Modules/LIS/database/seeders/LabSettingDefinitionSeeder.php` — seeds the full LIS
`lis_accounting` group (the four Core entries above plus the external-lab and
commission account keys). See `lis` sibling topic for the per-key table.

---

## 3. Chart-of-Accounts default LEAF accounts

Posting needs the account **rows** to exist. They are seeded by:

`Modules/Accounting/database/seeders/DefaultChartOfAccountsSeeder.php`

- `run()` @ **line 19** builds a flat `$accounts` array (@ line 23) of ~50 rows with
  `code`, `name`, `name_ar`, `classification`, `nature`, `account_type`
  (`header` | `detail`), `level`, `parent_code`.
- Two-pass: pass 1 (@ line 84) `Account::firstOrCreate(['company_id','code'])`; pass 2
  (@ line 104) wires `parent_id` and flips parents' `has_children=true`.
- Company is injected via `setCompanyId()` @ **line 12** (defaults to 1).
- Registered in `Modules/Accounting/database/seeders/AccountingDatabaseSeeder.php:13`.

The **detail (leaf) accounts** posting/seeding rely on (`account_type => 'detail'`):

| Code | EN | AR | Class | File:line |
|---|---|---|---|---|
| 1101 | Cash on Hand | النقدية بالصندوق | assets | DefaultChartOfAccountsSeeder.php:27 |
| 1102 | Cash at Bank | النقدية بالبنك | assets | :28 |
| 1103 | Accounts Receivable | المدينون | assets | :29 |
| 1104 | Notes Receivable | أوراق القبض | assets | :30 |
| 1105 | Inventory | المخزون | assets | :31 |
| 2101 | Accounts Payable | الدائنون | liabilities | :41 |
| 2102 | Notes Payable | أوراق الدفع | liabilities | :42 |
| 2105 | Taxes Payable | ضرائب مستحقة | liabilities | :45 |
| 4101 | Sales Revenue | إيرادات المبيعات | revenue | :59 |
| 4102 | Service Revenue | إيرادات الخدمات | revenue | :60 |
| 4105 | Sales Discounts | خصومات المبيعات | revenue | :63 |
| 5101 | Cost of Goods Sold | تكلفة البضاعة المباعة | expenses | :69 |
| 5201 | Salaries & Wages | الرواتب والأجور | expenses | :71 |

Header parents that several modules lazily create children under: `11` Current
Assets (:26), `21` Current Liabilities (:40), `52` Operating Expenses (:70).

> **Header vs leaf trap:** `1103 المدينون` is a **detail** in the default chart, but it
> *flips to a header* once the first partner AR sub-account is auto-created under it.
> A JE line cannot post to a header account → the posting throws. Receivable settings
> must therefore point at a **detail leaf** (e.g. a `1103xx ذمم ...` child), never the
> AR parent once partners exist. (See the AR-header writeup in project memory.)

### 3.1 Resolving codes → ids (provisioning)
`SettingDefinitionSeeder::seedDefaultAccountSettings(int $companyId)` @ **line 2111**:

- Static `$accountCodeMap` @ **line 2113** maps setting_key → account **code**:
  - `sales.revenue_account_id` → `4101`
  - `sales.cogs_account_id` → `5101`
  - `sales.receivable_account_id` → `1103`
  - `sales.inventory_account_id` → `1105`
  - `sales.discount_account_id` → `4105`
  - `purchases.inventory_account_id` → `1105`
  - `purchases.payable_account_id` → `2101`
- Looks up `Account::where('company_id',$companyId)->whereIn('code', …)->pluck('id','code')`
  @ **line 2125**, then for each key inserts a `Setting` row **only if absent** (@ line
  2139 existing-check, @ line 2150 create) and finally forgets the company settings
  cache (@ line 2161). Returns the count seeded.
- **Only these 7 sales/purchases keys are auto-mapped.** LIS / HR / Production / POS
  account settings are **NOT** pre-populated here — they start `null` and must be set
  manually (or are lazily created at posting time for `production.*`).

### 3.2 Provisioning wiring
`Modules/Core/app/Actions/ImportTestData::execute()` (`Modules/Core/app/Actions/ImportTestData.php`):
- Line 34 `seedAccounts()` → runs `DefaultChartOfAccountsSeeder` (`new …; ->setCompanyId(); ->run()` @ lines 70-71).
- Line 38 `SettingDefinitionSeeder::seedDefaultAccountSettings($companyId)`.
This is the only non-test caller wiring chart-seed + account-setting-seed together.

---

## 4. The settings UPDATE API (what /setup posts to)

Route (Core module): `Modules/Core/routes/api.php`
```
GET  settings/definitions        → SettingController@definitions   (api.php:43)
GET  settings                    → SettingController@index         (api.php:44)
GET  settings/{key}              → SettingController@show          (api.php:45)
PUT  settings                    → SettingController@update        (api.php:46)
```
Full path: **`PUT /api/core/settings`**.

Controller: `Modules/Core/app/Http/Controllers/SettingController.php`
- `update(UpdateSettingRequest $request)` @ **line 121**:
  - `$companyId = auth()->user()->company_id;` (line 123) — tenant is implicit, never
    taken from the request body.
  - `$this->settingsService->set(setting_key, value, companyId, branch_id, user_id)` @ line 125.
  - Re-reads via `get()` and echoes `{ data: { setting_key, value } }` (line 140).
- **One key per request** — the body is a single `{setting_key, value}` pair (not a
  batch). The `/setup` page therefore PUTs each default account in its own request,
  chained sequentially (see frontend topic).

Validation: `…/Requests/UpdateSettingRequest.php` `rules()` @ line 17:
```php
'setting_key' => ['required','string','max:100'],
'value'       => ['required'],
'branch_id'   => ['nullable','integer','exists:branches,id'],
'user_id'     => ['nullable','integer','exists:users,id'],
```
> Note: `value` is only `required` — it is **not** validated to be an existing
> `accounts.id`. A bad account id is accepted and only blows up later at posting time.

---

## 5. How a setting's account-id is READ at posting time

`Modules/Core/app/Services/SettingsService.php`
- `get(string $key, int $companyId, ?int $branchId=null, ?int $userId=null)` @ **line 18**:
  fallback chain **user+branch → user → branch → company → definition default → null**
  (lines 24-56). Account settings are `scope=company`, so the company-level row (line
  46) is what posting reads.
- `set()` @ **line 62**: validates against `allowed_values` if present (lines 66-72),
  stringifies (`(string) $value`, line 74), `Setting::updateOrCreate` keyed on
  `(company_id, setting_key, branch_id, user_id)` (line 76), then flushes cache.
- Reads are served from a per-company cache: `getCompanySettings()` @ line 138 →
  `Cache::remember("settings.company.{$companyId}", …)`; invalidated by
  `flushCache()` @ line 182. **Gotcha:** after a manual DB tweak the value is stale
  until `Cache::forget("settings.company.{id}")`.

### 5.1 Canonical consumer pattern — `PostLabInvoice`
`Modules/LIS/app/Actions/PostLabInvoice.php` is the clearest worked example of the
read→cast→JE-line pattern. `createStandardJE()` @ **line 99**:
```php
$fallbackReceivableId = $this->settingsService->get('lis.receivable_account_id', $companyId);   // :101
$revenueAccountId     = $this->settingsService->get('lis.revenue_account_id', $companyId);       // :102
$taxPayableAccountId  = $this->settingsService->get('lis.tax_payable_account_id', $companyId);   // :103

$receivableAccountId = $this->resolvePartnerReceivableAccount($invoice->partner_id)              // :106
    ?? $fallbackReceivableId;

if (! $receivableAccountId || ! $revenueAccountId) {                                              // :109
    throw ValidationException::withMessages(['accounting' => [__('lis::lis.missing_accounting_settings')]]);
}
…
$lines[] = ['account_id' => (int) $receivableAccountId, 'debit' => $totalReceivable, … ];          // :122
$lines[] = ['account_id' => (int) $revenueAccountId,    'credit' => $totalRevenue,   … ];          // :132
$lines[] = ['account_id' => (int) $taxPayableAccountId, 'credit' => $totalTax,       … ];          // :143
return $this->createJournalEntry->execute([...header...], $lines);                                 // :169
```
Takeaways every posting Action shares:
1. `settingsService->get('<module>.<x>_account_id', $companyId)` → cast `(int)` → JE line `account_id`.
2. Many Actions prefer a **more specific** account first (partner / contract / device row)
   and fall back to the company-default setting:
   - Partner AR/AP from `AccBpExt` (`resolvePartnerReceivableAccount` @ line 321 → `ar_account_id`; lazily ensured via `ensurePartnerAccounts()`).
   - Insurance JE uses `contract?->insurance_receivable_account_id ?? lis.insurance_receivable_account_id ?? lis.receivable_account_id` (lines 188-191).
   - External-lab JE uses `externalLab?->expense_account_id ?? lis.external_lab_expense_account_id` (lines 271-276).
3. A missing required account throws a validation error at post time — there is **no**
   guard at save time (see §4 note).

Other posting Actions following the same shape: `PostLabPayment`,
`PostLabInvoiceItem`, `RecordExternalLabPayment` (all in `Modules/LIS/app/Actions/`);
Sales/Purchases/HR/POS equivalents covered in their sibling topics.

---

## 6. Lazy account creation (Production pattern)

`Modules/Production/app/Services/ProductionAccountResolver.php` is the alternative to
seeding: instead of a pre-mapped code, it reads the setting, and **if empty** it
find-or-creates a child detail account under the right header parent, then **writes
the new id back into the setting** so all future postings read a stable id.

- Setting keys (consts, lines 34-48): `production.wip_account_id`,
  `production.fg_account_id`, `production.scrap_account_id`,
  `production.labor_applied_account_id`, `production.overhead_applied_account_id`,
  `production.cost_variance_account_id`, `production.toll_clearing_account_id`,
  `production.customer_advance_account_id`.
- Parents (consts, lines 50-54): `11` Current Assets (WIP/FG), `52` Operating Expenses
  (scrap), `21` Current Liabilities (applied/clearing).
- Per-request memo cache `$cache` keyed `"{companyId}:{settingKey}"` (line 57).

Implication for the /setup extension: these keys **never have a seeded definition or
default**, so they will show as empty on the page until either a posting auto-creates
them or the admin sets them. (See manufacturing sibling topic for the full list.)

---

## 7. The 7 keys the current /setup page writes (legacy / orphan)

The existing `/setup` "Default Accounts" step posts these keys (FE
`src/app/features/setup/setup-wizard.component.ts:677-712`):

| /setup key | UI label | Status in BE |
|---|---|---|
| `accounting.default_cash_account` | Cash المدينون-cash | **No definition, no consumer** |
| `accounting.default_bank_account` | Bank | **No definition, no consumer** |
| `accounting.default_client_account` | Receivables المدينون | **No definition, no consumer** |
| `accounting.default_supplier_account` | Payables الدائنون | **No definition, no consumer** |
| `accounting.default_expense_account` | Expenses | **No definition, no consumer** |
| `accounting.default_revenue_account` | Revenue | **No definition, no consumer** |
| `accounting.default_check_account` | Notes/Check | **No definition, no consumer** |

**Critical finding:** a whole-BE grep for these exact 7 keys returns **zero** hits in
`Modules/**/*.php` — they are neither seeded as `SettingDefinition`s nor read by any
posting Action. They are stored (the PUT endpoint accepts any `setting_key`) but
**inert**. The only near-match is
`Modules/LIS/app/Services/DoctorCommissionService.php:385`, which reads
`accounting.default_cash_account_id` (note the **`_id` suffix** — a *different* key).

➡️ When extending `/setup`, the page should target the **real posting keys**
(`sales.*`, `purchases.*`, `lis.*`, `hrm.*`, `production.*`, `pos.*` listed in §2 / the
sibling topics), not these 7 orphans — and ideally reconcile the existing 7 (rename to
the canonical `accounting.*_account_id` / module keys, or seed definitions + wire
consumers) so the data already collected isn't lost.

---

## 8. Quick reference — key files

| Concern | File:line |
|---|---|
| Definition catalog (master) | `Modules/Core/database/seeders/SettingDefinitionSeeder.php` (run :10, getDefinitions :25) |
| Auto-seed sales/purchases account settings | same file, `seedDefaultAccountSettings()` :2111 |
| LIS definition catalog | `Modules/LIS/database/seeders/LabSettingDefinitionSeeder.php` |
| Chart leaf accounts | `Modules/Accounting/database/seeders/DefaultChartOfAccountsSeeder.php` (run :19) |
| Provisioning wiring | `Modules/Core/app/Actions/ImportTestData.php:34,38,70-71` |
| Settings store/read service | `Modules/Core/app/Services/SettingsService.php` (get :18, set :62, cache :138/:182) |
| Settings UPDATE API | route `Modules/Core/routes/api.php:46` → `SettingController@update` :121; validation `Requests/UpdateSettingRequest.php:17` |
| Canonical posting consumer | `Modules/LIS/app/Actions/PostLabInvoice.php:99-180` |
| Lazy account creation pattern | `Modules/Production/app/Services/ProductionAccountResolver.php` |
| FE /setup page (writes 7 orphan keys) | `moon-erp/src/app/features/setup/setup-wizard.component.ts:677-712` |
