# TradiXor — Developer PRD

**Version:** 2.0.0 | **Audience:** Engineers only | **Date:** May 2026

---

## 1. Stack

| Layer | Package | Version |
|---|---|---|
| Runtime | PHP | 8.5 |
| Framework | Laravel | v13 |
| Frontend bridge | inertia-laravel | v3 |
| Auth backend | laravel/fortify | v1 |
| API tokens | laravel/sanctum | v4 |
| Route typing | laravel/wayfinder | v0 |
| URL helpers | tightenco/ziggy | v2 |
| UI scaffolding | laravel/boost | v2 |
| Media | spatie/laravel-medialibrary | (via boost) |
| Activity log | spatie/laravel-activitylog | (via boost) |
| RBAC | laratrust | (via boost) |
| Testing | phpunit/phpunit | v12 |
| Vue | vue | v3 |
| Inertia client | @inertiajs/vue3 | v3 |
| CSS | tailwindcss | v4 |
| Linter | eslint | v9 |
| Formatter | prettier | v3 |
| PHP formatter | laravel/pint | v1 |
| DB | PostgreSQL (primary) / MySQL 8.0.16+ compatible | — |

---

## 2. Architecture

**Modular monolith.** Domain boundaries are enforced through service classes and model namespaces. No domain controller directly queries another domain's tables — inter-domain reads go through service contracts.

### Directory layout (key paths)

```
app/
├── Http/
│   ├── Controllers/
│   │   ├── Auth/                    # Fortify auth controllers
│   │   ├── Settings/                # Profile, password, 2FA, notifications
│   │   ├── UserManagement/          # Users, roles, permissions, sessions
│   │   ├── System/                  # Currencies, countries, orgs, designations, UOMs
│   │   ├── Procurement/             # (to build) Sourcing trips, PIs, supplier payments
│   │   ├── Logistics/               # (to build) Shipments, events, haulage, clearance
│   │   ├── Inventory/               # (to build) Lots, stock, transfers, stock takes
│   │   ├── LocalProcurement/        # (to build) LPOs, GRNs, supplier invoices
│   │   ├── Production/              # (to build) BOMs, production orders
│   │   ├── Sales/                   # (to build) Sales, instalments, returns, till
│   │   └── Finance/                 # (to build) COA, journals, fiscal periods, expenses
│   ├── Requests/                    # Form request classes (mirrored namespace)
│   └── Middleware/
├── Models/
│   ├── Traits/
│   │   ├── HasAuditColumns.php      # Auto-fills created_by, updated_by, creator_org_id
│   │   └── BelongsToOrganization.php# Global scope: organization_id = auth user's org
│   ├── System/                      # ShippingPort, ProductCategory, Product, Supplier,
│   │                                #   ShippingAgent, CFA, HaulageCompany, InsuranceCompany,
│   │                                #   ProductLocation, Department, Customer
│   ├── Procurement/                 # SourcingTrip, SourcingTripItem, ProformaInvoice,
│   │                                #   ProformaInvoiceItem, SupplierPayment
│   ├── Logistics/                   # Shipment, ShipmentEvent, ShipmentItem,
│   │                                #   ShippingDocument, TaxClearanceRecord, HaulageRecord
│   ├── Inventory/                   # CostAllocation, InventoryLot, StockLevel,
│   │                                #   StockMovement, StockTake, StockTakeItem,
│   │                                #   SerialNumber, TransferOrder, TransferOrderItem
│   ├── LocalProcurement/            # LocalPurchaseOrder, LocalPurchaseOrderItem,
│   │                                #   GoodsReceiptNote, GoodsReceiptNoteItem,
│   │                                #   SupplierInvoice, SupplierInvoiceItem
│   ├── Production/                  # BillOfMaterials, BomItem, ProductionOrder,
│   │                                #   ProductionCostLine, ProductionMaterialConsumption
│   ├── Sales/                       # TaxRate, TillSession, Sale, SaleLineItem,
│   │                                #   InstalmentPlan, InstalmentPayment, Receipt,
│   │                                #   SalePayment, SalesReturn, SalesReturnItem
│   ├── Finance/                     # FiscalPeriod, BankAccount, ChartOfAccount,
│   │                                #   JournalEntry, JournalEntryLine,
│   │                                #   BankReconciliation, Expense
│   ├── Hr/                          # Employee
│   ├── User.php
│   └── (Security/, Api/ — existing)
├── Services/                        # One service class per domain aggregate
│   └── System/                      # (existing) SysGeneratorService, etc.
└── Providers/
    └── AppServiceProvider.php       # morphMap registered here
resources/
└── js/
    ├── pages/                       # Inertia page components (PascalCase subdirs by domain)
    ├── components/                  # Shared Vue components
    ├── layouts/                     # App shell layout
    ├── composables/                 # Vue composables
    └── data/                        # modules.ts, other static data
```

---

## 3. Multi-Tenancy

Every organization is a fully isolated tenant. Isolation is enforced at the Eloquent layer via a **global query scope**, not at the DB level.

### How it works

The `BelongsToOrganization` trait (used on every model with `organization_id`) boots a global scope:

```php
static::addGlobalScope('organization', function (Builder $builder) {
    if (Auth::check()) {
        $builder->where(
            $builder->getModel()->qualifyColumn('organization_id'),
            Auth::user()->organization_id
        );
    }
});
```

Every query against a tenanted model is automatically filtered. An authenticated user from org 5 cannot retrieve records from org 7, even with a known ID.

### Escape hatches

```php
Model::withoutGlobalScope('organization')         // bypass for this query
Model::allOrganizations()                         // scope alias for above
Model::forOrganization($id)                       # explicit org query (admin use)
```

Use escape hatches only in: super-admin controllers, scheduled jobs, and seed/migration code.

### The `creator_organization_id` column

Present on every table. Records which org's context was active when a record was created. Allows cross-org audit trails and distinguishes platform-seeded rows (creator_organization_id = 1 = Tradixor platform org).

---

## 4. Audit Columns

Every table carries three extra columns:

| Column | Type | Populated by |
|---|---|---|
| `created_by` | bigint nullable FK → users | `HasAuditColumns::bootHasAuditColumns()` on creating |
| `updated_by` | bigint nullable FK → users | `HasAuditColumns::bootHasAuditColumns()` on creating + updating |
| `creator_organization_id` | bigint nullable FK → organizations | `HasAuditColumns::bootHasAuditColumns()` on creating |

All three are nullable to handle: seeded records, scheduled job writes, and the bootstrap problem (org created before any user session).

The `HasAuditColumns` trait uses `??=` so existing values (set by seeders) are never overwritten.

---

## 5. Database Schema

### Migration execution order

```
0000_01_01_000000  — currencies, countries, organizations, designations, users,
                     units_of_measure, sessions, login_records, security_events,
                     trusted_devices, api_request_logs
0000_01_01_000002  — shipping_ports, product_categories, products, suppliers,
                     shipping_agents, clearing_forwarding_agents, haulage_companies,
                     insurance_companies, product_locations, departments, customers
0000_01_01_000003  — sourcing_trips, sourcing_trip_items, proforma_invoices,
                     proforma_invoice_items, supplier_payments
0000_01_01_000004  — shipments, shipment_events, shipment_items, shipping_documents,
                     tax_clearance_records, haulage_records
0000_01_01_000006  — cost_allocations, inventory_lots, stock_levels, stock_movements,
(inventory)          stock_takes, stock_take_items, serial_numbers,
                     transfer_orders, transfer_order_items
0000_01_01_000006  — local_purchase_orders, local_purchase_order_items,
(local procure)      goods_receipt_notes, goods_receipt_note_items,
                     supplier_invoices, supplier_invoice_items
0000_01_01_000007  — bill_of_materials, bom_items, production_orders,
                     production_cost_lines, production_material_consumptions
0000_01_01_000008  — tax_rates, till_sessions, sales, sale_line_items,
                     instalment_plans, instalment_payments, receipts,
                     sale_payments, sales_returns, sales_return_items
0000_01_01_000009  — fiscal_periods, bank_accounts (+ deferred FKs from 0003/0008),
                     chart_of_accounts, journal_entries, journal_entry_lines,
                     bank_reconciliations, expenses
0000_01_01_000010  — employees
0000_01_01_000016  — seed: currencies, countries, shipping_ports, platform org,
                     platform admin user, default UOMs, default COA
```

### Key schema decisions

**All monetary values** — `DECIMAL(15,4)`. No floats anywhere in the schema.

**Natural FKs** — `currency_code CHAR(3)` references `currencies.code` (ISO 4217). `country_code CHAR(2)` references `countries.code` (ISO 3166-1). Self-documenting in every query and export.

**Surrogate PKs** — Every other table uses bigint auto-increment. Tenant scoping (not ID opacity) provides security.

**Soft deletes** — All financial and catalogue records use `deleted_at`. Hard delete prohibited by convention.

**DB-level check constraint on `journal_entry_lines`:**
```sql
((debit > 0 AND credit = 0) OR (credit > 0 AND debit = 0))
```
Enforced at DB level (PostgreSQL and MySQL 8.0.16+). Balance invariant (SUM debits = SUM credits) enforced additionally at service layer before posting.

### Enum reference

| Table | Column | Values |
|---|---|---|
| `shipping_ports` | type | sea, air, road, rail, inland |
| `sourcing_trips` | status | draft, in_progress, sourcing_complete, shipped, closed |
| `sourcing_trip_items` | status | planned, sourced, partially_sourced, unavailable |
| `proforma_invoices` | status | draft, sent, confirmed, expired, cancelled |
| `supplier_payments` | payment_method | wire_transfer, letter_of_credit, mobile_money, cash, cheque, other |
| `supplier_payments` | payable_type | proforma_invoice, supplier_invoice, direct |
| `shipments` | mode | sea, air, road, rail, multimodal |
| `shipments` | status | pending, in_transit, arrived, cleared, delivered |
| `shipment_events` | event_type | booking_confirmed, container_stuffed, vessel_departed, transshipment_arrived, transshipment_departed, destination_arrived, customs_exam_requested, customs_exam_completed, customs_released, handed_to_haulier, delivered_to_warehouse, other |
| `tax_clearance_records` | status | pending, assessed, paid, cleared |
| `haulage_records` | status | pending, in_transit, delivered |
| `cost_allocations` | allocation_type | freight, insurance, clearing_fee, haulage, tax, other_charge |
| `cost_allocations` | method_used | equal, by_value, by_weight, by_volume, manual |
| `inventory_lots` | source_type | import, local_purchase, production, opening_stock |
| `inventory_lots` | costing_method | fifo, wac |
| `inventory_lots` | selling_price_method | fixed, margin_pct, markup_amount |
| `inventory_lots` | outlet_price_method | pct_above_selling, fixed_above_selling |
| `stock_movements` | movement_type | receipt, transfer_in, transfer_out, sale, return, adjustment, write_off |
| `stock_movements` | adjustment_reason | physical_count_correction, damage, theft, expiry, supplier_return, customer_return, write_off, system_correction, other, goods_not_received, goods_in_transit, natural_wastage, wrong_location, sample_usage, staff_consumption, breakage *(constraint removed — free text accepted)* |
| `serial_numbers` | status | in_stock, reserved, sold, transferred, written_off, pending_dispatch, pending_receipt |
| `stock_takes` | status | draft, in_progress, completed, posted, cancelled |
| `stock_takes` | counting_mode | serial, manual |
| `transfer_orders` | status | draft, pending_approval, approved, in_transit, received, cancelled |
| `local_purchase_orders` | status | draft, ordered, partially_received, received, cancelled |
| `goods_receipt_notes` | status | draft, posted |
| `goods_receipt_note_items` | condition | good, damaged, short |
| `supplier_invoices` | status | unpaid, partially_paid, paid, overdue, disputed |
| `production_orders` | status | planned, in_production, completed, cancelled |
| `production_cost_lines` | cost_type | material, labour, overhead, other |
| `tax_rates` | tax_type | vat, withholding, excise, other |
| `tax_rates` | applies_to | sales, purchases, both |
| `till_sessions` | status | open, closed |
| `sales` | sale_type | full_payment, instalment |
| `sales` | status | pending, active, fully_paid, forfeited, cancelled |
| `sales` | delivery_type | self_collect, free_delivery, paid_delivery |
| `instalment_plans` | storage_fee_type | fixed, daily_accrual |
| `instalment_payments` | payment_method | cash, mobile_money, bank_transfer, card, cheque |
| `receipts` | receipt_type | full, instalment_deposit, instalment_payment, delivery, refund |
| `sale_payments` | payment_method | cash, mobile_money, bank_transfer, card, cheque |
| `sales_returns` | refund_method | cash, mobile_money, bank_transfer, card, store_credit |
| `sales_returns` | status | pending, approved, processed, cancelled |
| `sales_return_items` | condition | resaleable, damaged, write_off |
| `fiscal_periods` | period_type | month, quarter, year |
| `fiscal_periods` | status | open, closed, locked |
| `chart_of_accounts` | type | asset, liability, equity, revenue, expense |
| `chart_of_accounts` | normal_balance | debit, credit |
| `expenses` | payment_method | cash, mobile_money, bank_transfer, card, cheque, accrual |
| `expenses` | status | draft, approved, posted, rejected |
| `employees` | employment_type | full_time, part_time, casual, contract |
| `employees` | status | active, on_leave, terminated |

---

## 6. Polymorphic Relationships

### Two true Laravel morphs (use morphMap aliases in DB)

| Model | Relationship | Columns | Resolved via |
|---|---|---|---|
| `StockMovement` | `reference()` | `reference_type`, `reference_id` | `morphTo()` + `Relation::morphMap()` |
| `JournalEntry` | `source()` | `source_type`, `source_id` | `morphTo()` + `Relation::morphMap()` |

Both use `nullableMorphs()` in migrations (varchar 255, nullable, auto-indexed).

### morphMap (registered in `AppServiceProvider::registerMorphMap()`)

| Alias stored in DB | Model class |
|---|---|
| `sale` | `App\Models\Sales\Sale` |
| `sale_return` | `App\Models\Sales\SalesReturn` |
| `instalment_payment` | `App\Models\Sales\InstalmentPayment` |
| `receipt` | `App\Models\Sales\Receipt` |
| `sourcing_trip` | `App\Models\Procurement\SourcingTrip` |
| `proforma_invoice` | `App\Models\Procurement\ProformaInvoice` |
| `supplier_payment` | `App\Models\Procurement\SupplierPayment` |
| `lpo` | `App\Models\LocalProcurement\LocalPurchaseOrder` |
| `grn` | `App\Models\LocalProcurement\GoodsReceiptNote` |
| `supplier_invoice` | `App\Models\LocalProcurement\SupplierInvoice` |
| `shipment` | `App\Models\Logistics\Shipment` |
| `haulage_record` | `App\Models\Logistics\HaulageRecord` |
| `tax_clearance` | `App\Models\Logistics\TaxClearanceRecord` |
| `inventory_lot` | `App\Models\Inventory\InventoryLot` |
| `transfer_order` | `App\Models\Inventory\TransferOrder` |
| `stock_take` | `App\Models\Inventory\StockTake` |
| `production_order` | `App\Models\Production\ProductionOrder` |
| `expense` | `App\Models\Finance\Expense` |
| `bank_reconciliation` | `App\Models\Finance\BankReconciliation` |
| `journal_entry` | `App\Models\Finance\JournalEntry` |

### Two enum-based pseudo-polymorphics (NOT morphTo)

These have `source_type`/`payable_type` columns that are ENUMs with domain codes, not model class names. They use `match` in a `source()` / `payable()` method instead of `morphTo()`.

| Model | Column | Enum values → related model |
|---|---|---|
| `InventoryLot` | `source_type` | import→Shipment, local_purchase→LocalPurchaseOrder, production→ProductionOrder, opening_stock→null |
| `SupplierPayment` | `payable_type` | proforma_invoice→ProformaInvoice, supplier_invoice→SupplierInvoice, direct→null |

---

## 7. Seeded Reference Data

Migration `0000_01_01_000016` creates on every fresh install:

| Entity | Count | Notes |
|---|---|---|
| Currencies | 35 | ISO 4217; major world + all African currencies |
| Countries | 90 | ISO 3166-1; full Africa + all major trading nations |
| Shipping ports | 33 | Major Chinese, UAE, Indian, Singapore, East African ports |
| Platform organization | 1 | `id=1`, `is_administrative=true`, slug=`tradixor`, country=UG, currency=UGX |
| Platform admin user | 1 | `id=1`, `is_super_admin=true`, `email=admin@tradixor.com`, `password_expires_at=now()` |
| Units of measure | 18 | Seeded into platform org; copied to tenant orgs on signup |
| Chart of accounts | 36 | Seeded into platform org as master template; copied to tenant orgs on signup |

**Important:** The COA seed uses `is_system=true` on all seeded accounts. Tenant orgs get a copy via `OrganizationSeederService` (to be built) — they can add custom accounts but cannot delete system ones.

---

## 8. Model Inventory (59 models)

### Traits applied

| Trait | Applied to |
|---|---|
| `HasAuditColumns` | All 59 models |
| `BelongsToOrganization` | All models with `organization_id` column (~45 models) |
| `SoftDeletes` | product_categories, products, suppliers, shipping_agents, clearing_forwarding_agents, haulage_companies, insurance_companies, product_locations, departments, customers, sourcing_trips, proforma_invoices, shipments, haulage_records, transfer_orders, stock_takes, local_purchase_orders, goods_receipt_notes, supplier_invoices, bill_of_materials, production_orders, tax_rates, sales, sales_returns, fiscal_periods, bank_accounts, chart_of_accounts, expenses, employees |

### Models without organization_id (no tenant scope)

`ShippingPort` (platform-wide), `ProformaInvoiceItem`, `ShipmentItem`, `ShippingDocument`, `ShipmentEvent`, `StockLevel`, `StockTakeItem`, `TransferOrderItem`, `LocalPurchaseOrderItem`, `GoodsReceiptNoteItem`, `SupplierInvoiceItem`, `BomItem`, `ProductionCostLine`, `ProductionMaterialConsumption`, `SaleLineItem`, `InstalmentPlan`, `InstalmentPayment`, `SalePayment`, `SalesReturnItem`, `JournalEntryLine`

### Encrypted columns (use Eloquent `encrypted` cast)

| Model | Column |
|---|---|
| `BankAccount` | `account_number` |
| `Customer` | `id_number` |
| `Employee` | `salary` |

---

## 9. Spatie Media Library Collections

| Model | Collection | Cardinality | Conversions |
|---|---|---|---|
| `Organization` | `logo` | single | — |
| `User` | `avatar` | single | thumb 150×150, preview 300×300 |
| `User` | `signature` | single | — |
| `Product` | `product_photos` | multiple | thumb 200×200, preview 800×800 |
| `Product` | `product_thumbnail` | single | — |
| `Employee` | `profile_photo` | single | — |
| `Shipment` | `shipping_documents` | multiple | — |
| `TaxClearanceRecord` | `clearance_documents` | multiple | — |
| `LocalPurchaseOrder` | `lpo_documents` | multiple | — |
| `ProformaInvoice` | `proforma_invoice_documents` | multiple | — |
| `GoodsReceiptNote` | `grn_documents` | multiple | — |
| `Receipt` | `receipt_pdf` | single | — |
| `Supplier` | `supplier_documents` | multiple | — |
| `Customer` | `customer_documents` | multiple (signed URLs only) | — |

All collections use S3-compatible storage with org-isolated paths: `{organization_id}/{collection}/{filename}`.

**To implement `HasMedia` on a model:**
1. Implement `Spatie\MediaLibrary\HasMedia` interface
2. `use Spatie\MediaLibrary\InteractsWithMedia`
3. Define `registerMediaCollections()` and optionally `registerMediaConversions()`

`Product` already has this implemented. Use it as the reference.

---

## 10. Key Business Rules (as code requirements)

### Tenant isolation
- Every query against a tenanted model must go through the global scope.
- Controllers must never call `withoutGlobalScope('organization')` unless explicitly building a cross-org admin feature.

### FIFO costing
- When a sale is created, `SaleService` must select the oldest available `InventoryLot` for each product at the sale's location (oldest by `received_date`, then `id`).
- `lot_id` on `SaleLineItem` is mandatory — no sale line may exist without a lot reference.

### WAC costing
- When `inventory_lots.costing_method = 'wac'`, on every receipt: `new_avg = ((old_qty × old_avg) + (new_qty × new_cost)) / (old_qty + new_qty)`. Update `stock_levels.average_cost`.
- On every issue (sale/transfer): debit COGS using `average_cost`, not `landed_cost_per_unit`.

### Stock movement ledger (immutable)
- `stock_movements` is append-only. No updates or deletes.
- Every quantity change to `stock_levels` must be preceded by a `stock_movements` insert.
- `StockMovementService` is the only class permitted to write to both tables in the same transaction.

### Double-entry accounting invariant
- `JournalEntry` cannot be posted unless `SUM(lines.debit) === SUM(lines.credit)`.
- A DB check constraint on `journal_entry_lines` prevents a line from being both a debit and a credit simultaneously.
- Enforce the balance check at service layer before calling `posted_at = now()`.

### Fiscal period gate
- No journal entry may be posted to a `fiscal_period` with `status = 'closed'` or `'locked'`.
- Check `FiscalPeriod::isOpen()` before posting.

### Instalment forfeiture (scheduled job)
- Daily job checks all instalment plans where `clearance_deadline + max_holding_days < today` and `sale.status = 'active'`.
- On forfeiture: unreserve stock (`stock_levels.quantity_reserved--`), set `sale.status = 'forfeited'`, compute `refund_amount = amount_paid - storage_fees_accrued`, record `forfeited_at`.

### Three-way matching (local procurement)
- A `SupplierInvoice` cannot transition to `paid` status until it is linked to at least one `GoodsReceiptNote` (`grn_id` is set).
- `LPO → GRN → Invoice` is the required posting sequence.
- `local_purchase_order_items.received_quantity` is a denormalized running total — updated by `GrnPostingService`, never set directly.

### Shipment delivery gate
- `Shipment` cannot advance to `status = 'delivered'` unless `taxClearanceRecord.status = 'cleared'`.
- Enforce in `ShipmentService::markDelivered()`.

### Signed URLs for public access
- Customer receipt share links: `URL::signedRoute('receipts.view', ['receipt' => $receipt], now()->addDays(7))`
- Customer document downloads: use Spatie temporary signed URLs, not direct S3 paths
- Payment reminder links: `URL::temporarySignedRoute(...)`

---

## 11. Reference Auto-Generation (codes)

All reference codes are generated by `SysGeneratorService` (exists). The following codes need sequence implementations:

| Code | Format | Examples |
|---|---|---|
| Organization | ORG-XXXXX | ORG-00001 |
| User | USR-XXXXX-NNNNN | USR-00001-00042 |
| Sourcing trip | TRIP-YYYY-NNN | TRIP-2026-001 |
| Proforma invoice | PI-YYYY-NNN | PI-2026-001 |
| Supplier payment | SPAY-YYYY-NNN | SPAY-2026-001 |
| Shipment | SHP-YYYY-NNN | SHP-2026-001 |
| Haulage record | HLG-YYYY-NNN | HLG-2026-001 |
| Inventory lot | LOT-YYYY-NNN | LOT-2026-001 |
| Stock take | STKT-YYYY-NNN | STKT-2026-001 |
| Transfer order | TRF-YYYY-NNN | TRF-2026-001 |
| LPO | LPO-YYYY-NNN | LPO-2026-001 |
| GRN | GRN-YYYY-NNN | GRN-2026-001 |
| Supplier invoice | SINV-YYYY-NNN | SINV-2026-001 |
| BOM | BOM-YYYY-NNN | BOM-2026-001 |
| Production order | PRD-YYYY-NNN | PRD-2026-001 |
| Sale | SALE-YYYY-NNNN | SALE-2026-0455 |
| Till session | TILL-YYYY-NNN | TILL-2026-001 |
| Receipt | (sequential per org) | org-specific |
| Sales return | RET-YYYY-NNN | RET-2026-001 |
| Expense | EXP-YYYY-NNN | EXP-2026-001 |
| Bank reconciliation | RECON-YYYY-NNN | RECON-2026-001 |

All sequences are per-organization and per-year. Use a DB-level sequence or a `MAX(reference) + 1` query scoped to the org + year in a DB transaction.

---

## 12. Authentication & Authorization

**Auth backend:** Laravel Fortify (routes registered automatically)
**RBAC:** Laratrust (roles, permissions, `HasRolesAndPermissions` on User)
**API auth:** Laravel Sanctum (token-based for API routes)
**Session auth:** Standard Laravel session (web routes)

### 2FA channels
Three channels implemented: `sms`, `email`, `authenticator` (TOTP). See `TwoFactorController` for flow.

### Authorization pattern (to implement)
Use Laravel Policies per model, registered in `AuthServiceProvider`:
```php
php artisan make:policy SourcingTripPolicy --model=SourcingTrip
```

Controllers use `$this->authorize('view', $sourcingTrip)` or `Gate::authorize()`. Policies check `organization_id` match AND role-based permission.

---

## 13. Frontend Architecture

**Framework:** Vue 3 (Composition API + `<script setup>`) + TypeScript
**Bridge:** Inertia.js v3 (`@inertiajs/vue3`)
**UI components:** shadcn-vue + Tailwind CSS v4
**Route typing:** Laravel Wayfinder — generates TypeScript functions from controllers
**URL helpers:** Ziggy (for named routes where Wayfinder doesn't apply)

### Page conventions
- All pages live in `resources/js/pages/` in PascalCase subdirectories matching the domain
- Page components receive props typed from the Inertia `InertiaPageProps` interface
- Use `useForm()` from `@inertiajs/vue3` for all form submissions (handles progress, errors, resets)
- Use Wayfinder imports instead of hardcoded URLs: `import { index } from '@/actions/Procurement/SourcingTripController'`
- Run `npm run wayfinder` (or the vite plugin handles it in dev) after adding controller routes

### Shared layout
- `resources/js/layouts/AppLayout.vue` — main authenticated shell (sidebar, topbar, notifications)
- Every page sets `defineOptions({ layout: AppLayout })` or uses `setLayoutProps()`

### Key composables to build
- `useOrganization()` — access current org from shared Inertia props
- `useCurrency()` — format monetary values using org's currency and locale
- `useStatus()` — map enum status strings to badge variants (from PRD §21.4)

---

## 14. Automated Journal Triggers Reference

Services that create journal entries must use these account codes (seeded):

| Event | Debit | Credit |
|---|---|---|
| Freight paid | 5100 Freight & Shipping Expense | 1000 Cash & Bank |
| Tax clearance paid | 5200 Import Duty Expense | 1000 Cash & Bank |
| Import goods received | 1200 Inventory — Warehouses | 2000 Accounts Payable |
| LPO goods received | 1200 Inventory — Warehouses | 2000 Accounts Payable |
| Local VAT on purchase | 1400 Input VAT | 2000 Accounts Payable |
| Payment to supplier | 2000 Accounts Payable | 1000 Cash & Bank |
| Materials consumed (production) | 1300 Work in Progress | 1200 Inventory — Warehouses |
| Labour/overhead incurred | 1300 Work in Progress | 2300 Accrued Liabilities |
| Production completed | 1200 Inventory — Warehouses | 1300 Work in Progress |
| Opening stock loaded | 1200 Inventory — Warehouses | 3100 Opening Balance Equity |
| Transfer: Warehouse → Outlet | 1210 Inventory — Outlets | 1200 Inventory — Warehouses |
| Full payment sale | 1000 Cash & Bank | 4000 Sales Revenue |
| COGS on full payment | 5000 Cost of Goods Sold | 1210 Inventory — Outlets |
| Instalment deposit received | 1000 Cash & Bank | 2100 Customer Deposits |
| Instalment fully paid | 2100 Customer Deposits | 4000 Sales Revenue |
| COGS on instalment completion | 5000 Cost of Goods Sold | 1210 Inventory — Outlets |
| Storage fee accrual | 2200 Storage Fees Payable | 4200 Storage Fee Income |
| Forfeiture refund | 2100 Customer Deposits | 1000 Cash & Bank + 4200 Storage Fee Income |

---

## 15. Testing Requirements

- **Framework:** PHPUnit v12 via `php artisan test --compact`
- **Test type:** Feature tests for all business flows (not unit tests for trivial methods)
- **Factories:** Every model must have a factory before its feature tests are written
- **Principle:** Tests hit the real DB (SQLite in-memory or PostgreSQL test DB). No mocking of the database.
- **Coverage:** Happy path, failure path, authorization (cross-tenant access attempt), edge cases

**Minimum test coverage per domain:**
- Every `store` / `update` / `destroy` route must have at least one feature test
- Every status transition must be tested (e.g. shipment pending → delivered gate)
- Every journal trigger must assert the resulting `journal_entry_lines` rows
- Cross-tenant access: assert that org B cannot access org A's records (404/403)

---

## 16. Scheduled Jobs (to implement)

Register in `routes/console.php` or a `ConsoleServiceProvider`:

| Job | Schedule | Description |
|---|---|---|
| `ProcessInstalmentForfeiture` | Daily midnight | Forfeits plans past max_holding_days |
| `AccrueStorageFees` | Daily midnight | Adds daily storage fees to active overdue plans |
| `SendInstalmentReminders` | Daily 9:00 AM | Notifies customers: 3 days before deadline, 1 day before |
| `FlagOverdueSupplierInvoices` | Daily | Sets supplier_invoices.status = 'overdue' where past due_date |
| `CheckStockAlerts` | Every 4 hours | Notifies staff when stock_levels falls below minimum_stock_level |
| `CloseFiscalPeriods` | 1st of each month | Auto-closes prior month's fiscal period if still open |

---

## 17. What Is Already Built

| Area | Status |
|---|---|
| Database schema (all migrations) | ✅ Complete |
| All 59 Eloquent models + Phase 7–8 additional models | ✅ Complete |
| `HasAuditColumns` + `BelongsToOrganization` traits | ✅ Complete |
| morphMap in AppServiceProvider | ✅ Complete |
| Laravel Fortify auth (login, register, verify email, reset password) | ✅ Complete |
| 2FA (SMS, email, TOTP) | ✅ Complete |
| Session management, trusted devices, security events | ✅ Complete |
| Laratrust RBAC (roles, permissions, templates) | ✅ Complete |
| User management (CRUD, bulk assignment, activity) | ✅ Complete |
| System reference data (currencies, countries, orgs, designations, UOMs) | ✅ Complete |
| Seed data (currencies, countries, ports, platform org, admin user, UOMs, COA) | ✅ Complete |
| Vue + Inertia + Tailwind + shadcn-vue scaffold | ✅ Complete |
| Module landing page | ✅ Complete |
| Notification system (database + email, read/unread, settings) | ✅ Complete |
| API request logging + instance management | ✅ Complete |
| **Phase 1–6** — Organization, Product Catalogue, Suppliers/Customers, Int'l Procurement, Logistics, Costing | ✅ Complete |
| **Phase 7 — Inventory: Lots, Stock Levels, Movements** | ✅ Complete |
| **Phase 8 — Inventory: Transfers, Stock Takes, Serial Numbers** | ✅ Complete |

### Phase 7 — Controllers, Services, Requests

| Component | File | Notes |
|---|---|---|
| `InventoryLotController` | `app/Http/Controllers/Inventory/InventoryLotController.php` | index, show, updateMeta, receiveFromShipment, setPricing, previewPricing, adjust (decrease-only) |
| `StockLevelController` | `app/Http/Controllers/Inventory/StockLevelController.php` | index (aggregate by product), byLot (per-lot rows) |
| `StockMovementController` | `app/Http/Controllers/Inventory/StockMovementController.php` | index |
| `OpeningStockController` | `app/Http/Controllers/Inventory/OpeningStockController.php` | store, downloadTemplate, previewImport, processImport |
| `InventoryLotService` | `app/Services/Inventory/InventoryLotService.php` | createFromShipment(), createOpeningStock(), createFromTransfer() |
| `StockMovementService` | `app/Services/Inventory/StockMovementService.php` | receive(), issue(), adjust() |
| `LotPricingService` | `app/Services/Inventory/LotPricingService.php` | setPricing(), previewPricing() |
| `SerialNumberService` | `app/Services/Inventory/SerialNumberService.php` | generate() |
| `LotPricingHistory` model | `app/Models/Inventory/LotPricingHistory.php` | Records every setPricing() call |
| `OpeningStockImport` | `app/Imports/OpeningStockImport.php` | 15-column Excel import; 4-step preview/process flow |

**Key Phase 7 design facts:**
- `stock_levels` is now **lot-based**: `lot_id` FK added; unique constraint is `(lot_id, location_id)`; one row per lot per location
- `InventoryLot` has `stockLevel()` HasOne (not HasMany); `stock_levels.lot_id` is set on every `receive()` call
- `StockMovementService` is the only class that writes to `stock_movements` AND `stock_levels`
- `LotPricingService::setPricing()` writes a `LotPricingHistory` record on every call; `reason` is mandatory (19 predefined options)
- Opening stock bulk import: 15-column Excel template; SKU or sys_code product lookup; flexible date parsing

### Phase 8 — Controllers, Services, Requests

| Component | File | Notes |
|---|---|---|
| `TransferOrderController` | `app/Http/Controllers/Inventory/TransferOrderController.php` | index, store, show, submit, approve, cancel, showDispatch, scanDispatch, unscanDispatch, finalizeDispatch, showReceive, scanReceive, unscanReceive, finalizeReceive |
| `TransferOrderItemController` | `app/Http/Controllers/Inventory/TransferOrderItemController.php` | store (updateOrCreate), update, destroy, updateVariance |
| `StockTakeController` | `app/Http/Controllers/Inventory/StockTakeController.php` | index, store, show, update (draft), initiate, scanSerial, unscanSerial, finalizeCount, updateItems, post, cancel, destroy (draft) |
| `SerialNumberController` | `app/Http/Controllers/Inventory/SerialNumberController.php` | index, generate, writeOff, printLabels |
| `TransferOrderService` | `app/Services/Inventory/TransferOrderService.php` | submit(), approve(), scanDispatch(), unscanDispatch(), dispatch(), scanReceipt(), unscanReceipt(), receive(), cancel() |
| `StockTakeService` | `app/Services/Inventory/StockTakeService.php` | initiate(), scanSerial(), unscanSerial(), finalizeCount(), updateItems(), post(), cancel() |
| `TransferOrderDocumentController` | `app/Http/Controllers/Inventory/Documents/TransferOrderDocumentController.php` | dispatchNote, dispatchNotePdf, receiptNote, receiptNotePdf |
| `InventoryLotDocumentController` | `app/Http/Controllers/Inventory/Documents/InventoryLotDocumentController.php` | lotDetail, lotDetailPdf |
| `StockTakeDocumentController` | `app/Http/Controllers/Inventory/Documents/StockTakeDocumentController.php` | countSheet, varianceReport (print-only, no PDF) |
| `StockReportDocumentController` | `app/Http/Controllers/Inventory/Documents/StockReportDocumentController.php` | stockPositionByLot, stockPositionByLotExcel, stockPositionByProduct, stockPositionByProductExcel, stockValuation, stockValuationExcel, reportsPage |

**Key Phase 8 design facts:**
- **Serial-based transfer**: `serial_numbers.pending_transfer_order_id` tracks serials through transit; `serial_numbers.pending_dispatch`/`pending_receipt` statuses; `pending_transfer_order_id` cleared only at receipt; orphaned transferred serials (dispatched but not received) get link cleared on receipt confirmation
- **Destination lot creation**: `InventoryLotService::createFromTransfer()` creates a new lot at the destination (`source_type='transfer'`); source lot is passed from the serial records (not from `TransferOrderItem.lot_id` which is null)
- **Stock takes**: `stock_take_id` FK on `serial_numbers` tracks counted serials; cleared on post/cancel
- **Write-off design**: status change only, no stock movement — prevents double-counting when used after a stock take
- **`adjustment_reason` constraint**: removed (free text); `StockTakeItem::VARIANCE_REASONS` provides the 16 predefined UI options, all aligned with the former constraint values
- **Excel exports**: `WithColumnWidths` replaces `ShouldAutoSize`; statuses/margins pre-computed in `map()` to avoid sheet reads in `styles()`

**Phase 8 Vue pages:**

| Page | Path |
|---|---|
| `TransferOrdersPage.vue` | `resources/js/pages/Inventory/Transfers/` |
| `ShowTransferOrder.vue` | 5 tabs: Overview, Available Stock, Items, Stock Preview, Dispatch/Receipt Variance |
| `DispatchTransfer.vue` | Scanner card + Order Items tab + Staged tab + Confirm Dispatch modal |
| `ReceiveTransfer.vue` | Mirror of dispatch; type `RECEIPT` to confirm |
| `SubmitTransferDialog.vue`, `ApproveTransferDialog.vue`, `VarianceReasonDialog.vue` | Dialogs |
| `ShowStockTake.vue` | Scan form (serial mode) + Stock Items tab + Counted Serials tab |
| `StockTakesPage.vue` | Counting mode selector at creation |
| `SerialNumbersPage.vue` | All 6 status cards; selectable DataTable; bulk write-off |
| `ReportsPage.vue` | `resources/js/pages/Inventory/Reports/` — unified reports page |
| `StockAdjustmentDialog.vue` | `resources/js/pages/Inventory/Lots/` — decrease-only adjustment |

**Phases 9–18 remain to be built.** Full specifications are in `docs/implementation-plan.md`.
