# Sales Module — Code Implementation Reference

This document is a precise, file-by-file reference for the Sales module. It covers every route, every controller method, every service method, and every model in the Sales subsystem, including how financial posting and stock movements are handled.

---

## Table of Contents

1. [Routes](#1-routes)
2. [Controllers](#2-controllers)
   - [SalesTaxRateController](#21-salestaxratecontroller)
   - [CustomerController](#22-customercontroller)
   - [QuotationController](#23-quotationcontroller)
   - [SalesOrderController](#24-salesordercontroller)
   - [InvoiceController](#25-invoicecontroller)
   - [SalesPaymentController](#26-salespaymentcontroller)
   - [SalesReturnController](#27-salesreturncontroller)
   - [SalesReportController](#28-salesreportcontroller)
3. [Services](#3-services)
   - [SalesTaxRateService](#31-salestaxrateservice)
   - [CustomerService](#32-customerservice)
   - [QuotationService](#33-quotationservice)
   - [SalesOrderService](#34-salesorderservice)
   - [InvoiceService](#35-invoiceservice)
   - [SalesPaymentService](#36-salespaymentservice)
   - [SalesReturnService](#37-salesreturnservice)
   - [SalesPostingService](#38-salespostingservice)
   - [SalesReportService](#39-salesreportservice)
4. [Models](#4-models)
   - [SalesTaxRate](#41-salestaxrate)
   - [Customer](#42-customer)
   - [SalesQuotation](#43-salesquotation)
   - [SalesQuotationLine](#44-salesquotationline)
   - [SalesOrder](#45-salesorder)
   - [SalesOrderLine](#46-salesorderline)
   - [Invoice](#47-invoice)
   - [InvoiceLine](#48-invoiceline)
   - [SalesPayment](#49-salespayment)
   - [SalesReturn](#410-salesreturn)
   - [SalesReturnLine](#411-salesreturnline)
5. [Key Logic Explanations](#5-key-logic-explanations)
   - [Invoice Total and Tax Calculation](#51-invoice-total-and-tax-calculation)
   - [Quotation to Sales Order Conversion](#52-quotation-to-sales-order-conversion)
   - [Sales Return: Stock and GL Effects](#53-sales-return-stock-and-gl-effects)
   - [Payment Status Tracking](#54-payment-status-tracking)
   - [Invoice GL Posting](#55-invoice-gl-posting)

---

## 1. Routes

All routes are registered under the `auth:admin` middleware group, inside the locale prefix (`/{locale}/admin/`). The route name prefix is `admin.` for the outer group, and individual group prefixes are noted per section.

All sales routes require the admin guard to be authenticated. Permission middleware values shown in the Permission column use Spatie's `permission:` middleware.

### Setup: Tax Rates

Group prefix: `sales/setup/tax-rates`  
Route name prefix: `dashboard.sales.setup.tax-rates.`

| Method | URI | Route Name | Controller@Method | Permission |
|--------|-----|------------|-------------------|------------|
| GET | `sales/setup/tax-rates` | `index` | `SalesTaxRateController@index` | `sales.tax_rates.view` |
| GET | `sales/setup/tax-rates/create` | `create` | `SalesTaxRateController@create` | `sales.tax_rates.create` |
| POST | `sales/setup/tax-rates` | `store` | `SalesTaxRateController@store` | `sales.tax_rates.create` |
| GET | `sales/setup/tax-rates/{taxRate}/edit` | `edit` | `SalesTaxRateController@edit` | `sales.tax_rates.update` |
| PUT | `sales/setup/tax-rates/{taxRate}` | `update` | `SalesTaxRateController@update` | `sales.tax_rates.update` |
| DELETE | `sales/setup/tax-rates/{taxRate}` | `destroy` | `SalesTaxRateController@destroy` | `sales.tax_rates.delete` |

### Customers

Group prefix: `sales/customers`  
Route name prefix: `dashboard.sales.customers.`

| Method | URI | Route Name | Controller@Method | Permission |
|--------|-----|------------|-------------------|------------|
| GET | `sales/customers` | `index` | `CustomerController@index` | `sales.customers.view` |
| GET | `sales/customers/create` | `create` | `CustomerController@create` | `sales.customers.create` |
| POST | `sales/customers` | `store` | `CustomerController@store` | `sales.customers.create` |
| GET | `sales/customers/{customer}/statement` | `statement` | `ARAPController@customerStatement` | `sales.reports.customer_statement.view` |
| GET | `sales/customers/{customer}/edit` | `edit` | `CustomerController@edit` | `sales.customers.update` |
| PUT | `sales/customers/{customer}` | `update` | `CustomerController@update` | `sales.customers.update` |
| DELETE | `sales/customers/{customer}` | `destroy` | `CustomerController@destroy` | `sales.customers.delete` |

### Quotations

Group prefix: `sales/quotations`  
Route name prefix: `dashboard.sales.quotations.`

| Method | URI | Route Name | Controller@Method | Permission |
|--------|-----|------------|-------------------|------------|
| GET | `sales/quotations` | `index` | `QuotationController@index` | `sales.quotations.view` |
| GET | `sales/quotations/create` | `create` | `QuotationController@create` | `sales.quotations.create` |
| POST | `sales/quotations` | `store` | `QuotationController@store` | `sales.quotations.create` |
| GET | `sales/quotations/{quotation}` | `show` | `QuotationController@show` | `sales.quotations.view` |
| GET | `sales/quotations/{quotation}/edit` | `edit` | `QuotationController@edit` | `sales.quotations.update` |
| PUT | `sales/quotations/{quotation}` | `update` | `QuotationController@update` | `sales.quotations.update` |
| POST | `sales/quotations/{quotation}/send` | `send` | `QuotationController@send` | `sales.quotations.send` |
| POST | `sales/quotations/{quotation}/accept` | `accept` | `QuotationController@accept` | `sales.quotations.accept` |
| POST | `sales/quotations/{quotation}/reject` | `reject` | `QuotationController@reject` | `sales.quotations.reject` |
| POST | `sales/quotations/{quotation}/convert` | `convert` | `QuotationController@convertToOrder` | `sales.quotations.convert` |
| DELETE | `sales/quotations/{quotation}` | `destroy` | `QuotationController@destroy` | `sales.quotations.delete` |

### Sales Orders

Group prefix: `sales/orders`  
Route name prefix: `dashboard.sales.orders.`

| Method | URI | Route Name | Controller@Method | Permission |
|--------|-----|------------|-------------------|------------|
| GET | `sales/orders` | `index` | `SalesOrderController@index` | `sales.orders.view` |
| GET | `sales/orders/create` | `create` | `SalesOrderController@create` | `sales.orders.create` |
| POST | `sales/orders` | `store` | `SalesOrderController@store` | `sales.orders.create` |
| GET | `sales/orders/{salesOrder}/edit` | `edit` | `SalesOrderController@edit` | `sales.orders.update` |
| PUT | `sales/orders/{salesOrder}` | `update` | `SalesOrderController@update` | `sales.orders.update` |
| GET | `sales/orders/{salesOrder}` | `show` | `SalesOrderController@show` | `sales.orders.view` |
| POST | `sales/orders/{salesOrder}/confirm` | `confirm` | `SalesOrderController@confirm` | `sales.orders.confirm` |
| POST | `sales/orders/{salesOrder}/cancel` | `cancel` | `SalesOrderController@cancel` | `sales.orders.cancel` |
| DELETE | `sales/orders/{salesOrder}` | `destroy` | `SalesOrderController@destroy` | `sales.orders.delete` |

### Invoices

Group prefix: `sales/invoices`  
Route name prefix: `dashboard.sales.invoices.`

| Method | URI | Route Name | Controller@Method | Permission |
|--------|-----|------------|-------------------|------------|
| GET | `sales/invoices` | `index` | `InvoiceController@index` | `sales.invoices.view` |
| GET | `sales/invoices/create` | `create` | `InvoiceController@create` | `sales.invoices.create` |
| POST | `sales/invoices` | `store` | `InvoiceController@store` | `sales.invoices.create` |
| GET | `sales/invoices/{invoice}/edit` | `edit` | `InvoiceController@edit` | `sales.invoices.update` |
| PUT | `sales/invoices/{invoice}` | `update` | `InvoiceController@update` | `sales.invoices.update` |
| GET | `sales/invoices/{invoice}` | `show` | `InvoiceController@show` | `sales.invoices.view` |
| GET | `sales/invoices/{invoice}/pdf` | `pdf` | `InvoiceController@pdf` | `sales.invoices.view` |
| POST | `sales/invoices/{invoice}/confirm` | `confirm` | `InvoiceController@confirm` | `sales.invoices.confirm` |
| POST | `sales/invoices/{invoice}/post` | `post` | `InvoiceController@post` | `sales.invoices.post` |
| POST | `sales/invoices/{invoice}/cancel` | `cancel` | `InvoiceController@cancel` | `sales.invoices.cancel` |
| DELETE | `sales/invoices/{invoice}` | `destroy` | `InvoiceController@destroy` | `sales.invoices.delete` |

### Payments

Group prefix: `sales/payments`  
Route name prefix: `dashboard.sales.payments.`

| Method | URI | Route Name | Controller@Method | Permission |
|--------|-----|------------|-------------------|------------|
| GET | `sales/payments` | `index` | `SalesPaymentController@index` | `sales.payments.view` |
| GET | `sales/payments/create` | `create` | `SalesPaymentController@create` | `sales.payments.create` |
| POST | `sales/payments` | `store` | `SalesPaymentController@store` | `sales.payments.create` |
| GET | `sales/payments/customer-invoices` | `customer-invoices` | `SalesPaymentController@customerInvoices` | `sales.payments.create` |
| GET | `sales/payments/{payment}` | `show` | `SalesPaymentController@show` | `sales.payments.view` |
| POST | `sales/payments/{payment}/post` | `post` | `SalesPaymentController@post` | `sales.payments.post` |
| POST | `sales/payments/{payment}/reverse` | `reverse` | `SalesPaymentController@reverse` | `sales.payments.reverse` |
| DELETE | `sales/payments/{payment}` | `destroy` | `SalesPaymentController@destroy` | `sales.payments.delete` |

### Returns

Group prefix: `sales/returns`  
Route name prefix: `dashboard.sales.returns.`

| Method | URI | Route Name | Controller@Method | Permission |
|--------|-----|------------|-------------------|------------|
| GET | `sales/returns` | `index` | `SalesReturnController@index` | `sales.returns.view` |
| GET | `sales/returns/create` | `create` | `SalesReturnController@create` | `sales.returns.create` |
| POST | `sales/returns` | `store` | `SalesReturnController@store` | `sales.returns.create` |
| GET | `sales/returns/customer-invoices` | `customer-invoices` | `SalesReturnController@customerInvoices` | `sales.returns.create` |
| GET | `sales/returns/invoice-lines` | `invoice-lines` | `SalesReturnController@invoiceLines` | `sales.returns.create` |
| GET | `sales/returns/{return}` | `show` | `SalesReturnController@show` | `sales.returns.view` |
| POST | `sales/returns/{return}/post` | `post` | `SalesReturnController@post` | `sales.returns.post` |
| DELETE | `sales/returns/{return}` | `destroy` | `SalesReturnController@destroy` | `sales.returns.create` |

### Reports

Group prefix: `sales/reports`  
Route name prefix: `dashboard.sales.reports.`

| Method | URI | Route Name | Controller@Method | Permission |
|--------|-----|------------|-------------------|------------|
| GET | `sales/reports` | `index` | `SalesReportController@index` | `sales.reports.view` |
| GET | `sales/reports/aging` | `aging` | `SalesReportController@aging` | `sales.reports.aging.view` |
| GET | `sales/reports/outstanding` | `outstanding` | `SalesReportController@outstanding` | `sales.reports.outstanding.view` |
| GET | `sales/reports/invoice-register` | `invoice-register` | `SalesReportController@invoiceRegister` | `sales.reports.invoice_register.view` |
| GET | `sales/reports/payment-register` | `payment-register` | `SalesReportController@paymentRegister` | `sales.reports.payment_register.view` |

---

## 2. Controllers

All controllers live in `app/Http/Controllers/Admin/Sales/`. Each is thin: it validates input via a FormRequest, calls a service, and returns a view or redirect.

### 2.1 SalesTaxRateController

File: `app/Http/Controllers/Admin/Sales/Setup/SalesTaxRateController.php`  
Namespace: `App\Http\Controllers\Admin\Sales\Setup`  
Dependencies injected: `SalesTaxRateService $service`

#### `index(): View`

- Triggered by `GET sales/setup/tax-rates`
- Calls `$this->service->getAll()` which returns a paginated list of `SalesTaxRate` records.
- Returns view `dashboard.admin.sales.setup.tax-rates.index` with `$records`.

#### `create(): View`

- Triggered by `GET sales/setup/tax-rates/create`
- Calls `$this->service->getAccounts()` to get all active postable finance accounts.
- Returns view `dashboard.admin.sales.setup.tax-rates.create` with `$accounts`.

#### `store(StoreSalesTaxRateRequest $request): RedirectResponse`

- Triggered by `POST sales/setup/tax-rates`
- Input is validated by `StoreSalesTaxRateRequest` (fields: `code`, `name`, `rate`, `tax_payable_account_id`, `is_active`, `is_default`).
- Calls `$this->service->create($request->validated())`.
- Redirects to `dashboard.sales.setup.tax-rates.index` with success flash.

#### `edit(SalesTaxRate $taxRate): View`

- Triggered by `GET sales/setup/tax-rates/{taxRate}/edit`
- Calls `$this->service->getAccounts()`.
- Returns view `dashboard.admin.sales.setup.tax-rates.edit` with `$taxRate` and `$accounts`.

#### `update(UpdateSalesTaxRateRequest $request, SalesTaxRate $taxRate): RedirectResponse`

- Triggered by `PUT sales/setup/tax-rates/{taxRate}`
- Input validated by `UpdateSalesTaxRateRequest`.
- Calls `$this->service->update($taxRate, $request->validated())`.
- Redirects to index with success flash.

#### `destroy(SalesTaxRate $taxRate): RedirectResponse`

- Triggered by `DELETE sales/setup/tax-rates/{taxRate}`
- Calls `$this->service->delete($taxRate)`.
- Redirects to index with success flash.

---

### 2.2 CustomerController

File: `app/Http/Controllers/Admin/Sales/CustomerController.php`  
Dependencies injected: `CustomerService $customerService`

#### `index(): View`

- Triggered by `GET sales/customers`
- Calls `$this->customerService->getAll()` — returns paginated customers with `branch` eager-loaded.
- Returns view `dashboard.admin.sales.customers.index` with `$customers`.

#### `create(): View`

- Triggered by `GET sales/customers/create`
- Calls `$this->customerService->getFormOptions()` — returns `branches` (active) and `accounts` (active, postable).
- Returns view `dashboard.admin.sales.customers.create` with spread options.

#### `store(StoreCustomerRequest $request): RedirectResponse`

- Triggered by `POST sales/customers`
- Input validated by `StoreCustomerRequest`.
- Calls `$this->customerService->create($request->validated())`.
- Redirects to `admin.dashboard.sales.customers.index` with success flash.

#### `edit(Customer $customer): View`

- Triggered by `GET sales/customers/{customer}/edit`
- Calls `$this->customerService->getFormOptions()`.
- Returns view `dashboard.admin.sales.customers.edit` with merged options and `$customer`.

#### `update(UpdateCustomerRequest $request, Customer $customer): RedirectResponse`

- Triggered by `PUT sales/customers/{customer}`
- Calls `$this->customerService->update($customer, $request->validated())`.
- Redirects to index with success flash.

#### `destroy(Customer $customer): RedirectResponse`

- Triggered by `DELETE sales/customers/{customer}`
- Calls `$this->customerService->delete($customer)` — performs a soft delete.
- Redirects to index with success flash.

---

### 2.3 QuotationController

File: `app/Http/Controllers/Admin/Sales/QuotationController.php`  
Dependencies injected: `QuotationService $quotationService`

#### `index(): View`

- Triggered by `GET sales/quotations`
- Calls `$this->quotationService->getAll()` — paginated, ordered by `quotation_date` desc.
- Returns view `dashboard.admin.sales.quotations.index` with `$quotations`.

#### `create(): View`

- Triggered by `GET sales/quotations/create`
- Calls `$this->quotationService->getFormOptions()` — returns `customers`, `items`, `branches`.
- Returns view `dashboard.admin.sales.quotations.create`.

#### `store(StoreQuotationRequest $request): RedirectResponse`

- Triggered by `POST sales/quotations`
- Calls `$this->quotationService->create($request->validated())` — runs inside DB transaction, generates quotation number, creates header and lines, sets status to `draft`.
- Redirects to `dashboard.sales.quotations.show` for the new quotation.

#### `show(SalesQuotation $quotation): View`

- Triggered by `GET sales/quotations/{quotation}`
- Eager-loads `customer`, `branch`, `lines.item`, `convertedOrder`.
- Returns view `dashboard.admin.sales.quotations.show`.

#### `edit(SalesQuotation $quotation): View`

- Triggered by `GET sales/quotations/{quotation}/edit`
- Guards: calls `abort_unless($quotation->isEditable(), 403)`. Only `draft` quotations are editable.
- Returns view `dashboard.admin.sales.quotations.edit`.

#### `update(UpdateQuotationRequest $request, SalesQuotation $quotation): RedirectResponse`

- Triggered by `PUT sales/quotations/{quotation}`
- Guards: `abort_unless($quotation->isEditable(), 403)`.
- Calls `$this->quotationService->update($quotation, $request->validated())` — updates header fields and re-syncs lines (deletes old lines, inserts new ones, recalculates `total_amount`).
- Redirects to show page.

#### `send(SalesQuotation $quotation): RedirectResponse`

- Triggered by `POST sales/quotations/{quotation}/send`
- Calls `$this->quotationService->send($quotation)`.
- Service throws `RuntimeException` if status is not `draft`.
- On success, redirects to show. On failure, redirects back with error.

#### `accept(SalesQuotation $quotation): RedirectResponse`

- Triggered by `POST sales/quotations/{quotation}/accept`
- Calls `$this->quotationService->accept($quotation)`.
- Service throws `RuntimeException` if status is not `sent`.

#### `reject(SalesQuotation $quotation): RedirectResponse`

- Triggered by `POST sales/quotations/{quotation}/reject`
- Calls `$this->quotationService->reject($quotation)`.
- Service throws `RuntimeException` if status is not `sent`.

#### `convertToOrder(SalesQuotation $quotation): RedirectResponse`

- Triggered by `POST sales/quotations/{quotation}/convert`
- Calls `$this->quotationService->convertToOrder($quotation)`.
- Service throws `RuntimeException` if status is not `accepted`.
- On success, redirects to `admin.dashboard.sales.orders.show` for the newly created order.

#### `destroy(SalesQuotation $quotation): RedirectResponse`

- Triggered by `DELETE sales/quotations/{quotation}`
- Calls `$this->quotationService->delete($quotation)`.
- Service throws `RuntimeException` if status is not `draft`.
- Redirects to index.

---

### 2.4 SalesOrderController

File: `app/Http/Controllers/Admin/Sales/SalesOrderController.php`  
Dependencies injected: `SalesOrderService $salesOrderService`

#### `index(): View`

- Triggered by `GET sales/orders`
- Calls `$this->salesOrderService->getAll()` — paginated list with `customer`, `branch` eager-loaded.
- Returns view `dashboard.admin.sales.orders.index`.

#### `create(): View`

- Triggered by `GET sales/orders/create`
- Calls `$this->salesOrderService->getFormOptions()` — returns `customers`, `items`, `branches`, `taxRates`, `quotations`.
- Returns view `dashboard.admin.sales.orders.create`.

#### `store(StoreSalesOrderRequest $request): RedirectResponse`

- Triggered by `POST sales/orders`
- Calls `$this->salesOrderService->create($request->validated())`.
- Service validates the customer is active, prepares lines (computing line subtotals, per-line discounts, per-line tax), calculates header-level totals, generates an order number, and saves header + lines in a DB transaction.
- Redirects to show page.

#### `show(SalesOrder $salesOrder): View`

- Triggered by `GET sales/orders/{salesOrder}`
- Eager-loads `customer`, `branch`, `creator`, `updater`, `lines.item`.
- Returns view `dashboard.admin.sales.orders.show`.

#### `edit(SalesOrder $salesOrder): View`

- Triggered by `GET sales/orders/{salesOrder}/edit`
- Eager-loads `lines.item`.
- Returns view `dashboard.admin.sales.orders.edit` with merged form options and `$salesOrder`.

#### `update(UpdateSalesOrderRequest $request, SalesOrder $salesOrder): RedirectResponse`

- Triggered by `PUT sales/orders/{salesOrder}`
- Calls `$this->salesOrderService->update($salesOrder, $request->validated())`.
- Service deletes old lines and creates new ones, recomputes all totals.
- Redirects to show page.

#### `confirm(SalesOrder $salesOrder): RedirectResponse`

- Triggered by `POST sales/orders/{salesOrder}/confirm`
- Calls `$this->salesOrderService->confirm($salesOrder)`.
- Service throws `RuntimeException` if status is not `draft`.
- Sets status to `confirmed`, records `confirmed_at` timestamp.

#### `cancel(SalesOrder $salesOrder): RedirectResponse`

- Triggered by `POST sales/orders/{salesOrder}/cancel`
- Calls `$this->salesOrderService->cancel($salesOrder)`.
- Service throws `RuntimeException` if already cancelled.
- Sets status to `cancelled`.

#### `destroy(SalesOrder $salesOrder): RedirectResponse`

- Triggered by `DELETE sales/orders/{salesOrder}`
- Calls `$this->salesOrderService->delete($salesOrder)`.
- Service throws `RuntimeException` if status is not `draft`.
- Deletes lines and header in a DB transaction. Redirects to index.

---

### 2.5 InvoiceController

File: `app/Http/Controllers/Admin/Sales/InvoiceController.php`  
Dependencies injected: `InvoiceService $invoiceService`, `SalesPostingService $salesPostingService`

#### `index(): View`

- Triggered by `GET sales/invoices`
- Calls `$this->invoiceService->getAll()` — paginated, `customer`, `branch`, `salesOrder` eager-loaded.
- Returns view `dashboard.admin.sales.invoices.index`.

#### `create(): View`

- Triggered by `GET sales/invoices/create`
- Calls `$this->invoiceService->getFormOptions()` — returns `customers`, `salesOrders`, `items`, `branches`, `taxRates`.
- Returns view `dashboard.admin.sales.invoices.create`.

#### `store(StoreInvoiceRequest $request): RedirectResponse`

- Triggered by `POST sales/invoices`
- Calls `$this->invoiceService->create($request->validated())`.
- Service validates the customer is active, optionally links to a sales order, prepares lines with per-line discount and tax, calculates totals, generates an invoice number, saves header + lines in DB transaction. Status starts as `draft`.
- Redirects to show page.

#### `show(Invoice $invoice): View`

- Triggered by `GET sales/invoices/{invoice}`
- Eager-loads `customer`, `salesOrder`, `branch`, `creator`, `updater`, `lines.item`, `lines.taxRate`.
- Returns view `dashboard.admin.sales.invoices.show`.

#### `pdf(Invoice $invoice): Response`

- Triggered by `GET sales/invoices/{invoice}/pdf`
- Eager-loads `customer`, `branch`, `lines.item`.
- Uses `Barryvdh\DomPDF\Facade\Pdf::loadView('dashboard.admin.sales.invoices.pdf', ...)`.
- Returns a PDF download response with filename `invoice-{invoice_number}.pdf`.

#### `edit(Invoice $invoice): View`

- Triggered by `GET sales/invoices/{invoice}/edit`
- Eager-loads `salesOrder`, `lines.item`.
- Returns view `dashboard.admin.sales.invoices.edit` with form options merged.

#### `update(UpdateInvoiceRequest $request, Invoice $invoice): RedirectResponse`

- Triggered by `PUT sales/invoices/{invoice}`
- Calls `$this->invoiceService->update($invoice, $request->validated())`.
- Service deletes old lines, creates new ones, recomputes totals.
- Redirects to show page.

#### `confirm(Invoice $invoice): RedirectResponse`

- Triggered by `POST sales/invoices/{invoice}/confirm`
- Calls `$this->invoiceService->confirm($invoice)`.
- Service throws `RuntimeException` if status is not `draft`. Sets status to `confirmed`.

#### `post(Invoice $invoice): RedirectResponse`

- Triggered by `POST sales/invoices/{invoice}/post`
- Calls `$this->salesPostingService->postInvoice($invoice)`.
- Service requires status to be `confirmed` and `sales_journal_id` to be configured in `Settings`. Posts GL entries (DR AR, CR Revenue, CR Tax Payable; also DR COGS / CR Inventory for stock items). Sets `status = posted`, `posted_at`, and `journal_entry_id` on the invoice.
- Redirects to show page.

#### `cancel(Invoice $invoice): RedirectResponse`

- Triggered by `POST sales/invoices/{invoice}/cancel`
- Calls `$this->invoiceService->cancel($invoice)`.
- Service throws `RuntimeException` if already cancelled. Sets status to `cancelled`.

#### `destroy(Invoice $invoice): RedirectResponse`

- Triggered by `DELETE sales/invoices/{invoice}`
- Calls `$this->invoiceService->delete($invoice)`.
- Service throws `RuntimeException` if not in `draft` status.
- Deletes lines and header in a DB transaction. Redirects to index.

---

### 2.6 SalesPaymentController

File: `app/Http/Controllers/Admin/Sales/SalesPaymentController.php`  
Dependencies injected: `SalesPaymentService $paymentService`

#### `index(): View`

- Triggered by `GET sales/payments`
- Calls `$this->paymentService->getAll()` — paginated with `customer`, `branch` eager-loaded.
- Returns view `dashboard.admin.sales.payments.index`.

#### `create(): View`

- Triggered by `GET sales/payments/create`
- Calls `$this->paymentService->getFormOptions()` — returns `customers`, `branches`, `methods` array.
- Returns view `dashboard.admin.sales.payments.create`.

#### `store(StoreSalesPaymentRequest $request): RedirectResponse`

- Triggered by `POST sales/payments`
- Input validated by `StoreSalesPaymentRequest`.
- Calls `$this->paymentService->create($request->validated())`.
- Service generates a payment number, creates the `SalesPayment` record with status `draft`, and attaches allocations to invoices via the `sales_payment_invoices` pivot table.
- Redirects to show page.

#### `show(SalesPayment $payment): View`

- Triggered by `GET sales/payments/{payment}`
- Eager-loads `customer`, `branch`, `creator`, `updater`, `invoices`, `reversedFrom`.
- Returns view `dashboard.admin.sales.payments.show`.

#### `post(SalesPayment $payment): RedirectResponse`

- Triggered by `POST sales/payments/{payment}/post`
- Calls `$this->paymentService->post($payment)`.
- Service throws `RuntimeException` if not in `draft` status. Applies each allocated amount to its invoice (updates `paid_amount` and `payment_status` on the invoice). Sets payment status to `posted`.
- Redirects to show page.

#### `reverse(SalesPayment $payment): RedirectResponse`

- Triggered by `POST sales/payments/{payment}/reverse`
- Calls `$this->paymentService->reverse($payment)`.
- Service throws `RuntimeException` if not in `posted` status. Reverses applied amounts on each linked invoice, creates a new `SalesPayment` with negative amount and status `posted`, marks original payment as `reversed`.
- Redirects to the show page of the new reversal payment.

#### `destroy(SalesPayment $payment): RedirectResponse`

- Triggered by `DELETE sales/payments/{payment}`
- Calls `$this->paymentService->delete($payment)`.
- Service throws `RuntimeException` if not `draft`. Detaches invoice allocations and deletes the payment record. Redirects to index.

#### `customerInvoices(Request $request): JsonResponse`

- Triggered by `GET sales/payments/customer-invoices?customer_id={id}`
- Input: `customer_id` query parameter (cast to int).
- Calls `$this->paymentService->getCustomerInvoices($customerId)`.
- Returns JSON array of unpaid/partially-paid confirmed invoices for the customer, each with `id`, `invoice_number`, `invoice_date`, `total_amount`, `paid_amount`, `balance`.
- Used by the payment create form to dynamically load available invoices for allocation.

---

### 2.7 SalesReturnController

File: `app/Http/Controllers/Admin/Sales/SalesReturnController.php`  
Dependencies injected: `SalesReturnService $returnService`

#### `index(): View`

- Triggered by `GET sales/returns`
- Calls `$this->returnService->getAll()` — paginated with `customer`, `originalInvoice` eager-loaded.
- Returns view `dashboard.admin.sales.returns.index`.

#### `create(): View`

- Triggered by `GET sales/returns/create`
- Calls `$this->returnService->getFormOptions()` — returns `customers`, `branches`, `warehouses`.
- Returns view `dashboard.admin.sales.returns.create`.

#### `store(StoreSalesReturnRequest $request): RedirectResponse`

- Triggered by `POST sales/returns`
- Calls `$this->returnService->create($request->validated())`.
- Service prepares lines (skipping zero-quantity lines), calculates totals, generates return number, saves header and lines in DB transaction. Status starts as `draft`.
- Redirects to show page.

#### `show(SalesReturn $return): View`

- Triggered by `GET sales/returns/{return}`
- Eager-loads `customer`, `originalInvoice`, `branch`, `warehouse`, `lines.item`, `creator`, `updater`.
- Returns view `dashboard.admin.sales.returns.show`.

#### `post(SalesReturn $return): RedirectResponse`

- Triggered by `POST sales/returns/{return}/post`
- Calls `$this->returnService->post($return)`.
- Service: validates return quantities do not exceed what was originally sold and not already returned; if `restocked` and `warehouse_id` are set, creates `StockMovement` records (type `in`) and applies them via `StockBalanceService`; calls `SalesPostingService::postReturn()` to write GL entries (skipped gracefully if finance is not configured); increases `paid_amount` on the original invoice by the return total amount and recalculates `payment_status`. Sets return status to `posted`.
- Redirects to show page.

#### `destroy(SalesReturn $return): RedirectResponse`

- Triggered by `DELETE sales/returns/{return}`
- Calls `$this->returnService->delete($return)`.
- Service throws `RuntimeException` if not `draft`. Deletes lines and header. Redirects to index.

#### `customerInvoices(Request $request): JsonResponse`

- Triggered by `GET sales/returns/customer-invoices?customer_id={id}`
- Returns JSON array of all `confirmed` invoices for the customer.
- Each entry has `id`, `invoice_number`, `invoice_date`, `total_amount`.

#### `invoiceLines(Request $request): JsonResponse`

- Triggered by `GET sales/returns/invoice-lines?invoice_id={id}`
- Returns JSON array of all `InvoiceLine` records for the given invoice, with item details.
- Each entry has `id`, `item_id`, `item_code`, `item_name` (translated), `quantity`, `unit_price`, `tax_amount`, `line_total`.
- Used by the create form to let the user select which lines to return.

---

### 2.8 SalesReportController

File: `app/Http/Controllers/Admin/Sales/SalesReportController.php`  
Dependencies injected: `SalesReportService $salesReportService`

#### `index(SalesReportFilterRequest $request): View`

- Triggered by `GET sales/reports`
- Accepts filters via `SalesReportFilterRequest`: `branch_id`, `customer_id`, `date_from`, `date_to`.
- If `$request->hasFilters()` is true, calls `$this->salesReportService->getReport($request->validated())` which returns `rows` (collection of confirmed invoice summaries) and `totals` (count, subtotal, tax, total).
- Without filters, returns empty rows and zero totals.
- Returns view `dashboard.admin.sales.reports.index` with `rows`, `totals`, `selectedFilters`, `hasFilters`, and filter option lists.

#### `aging(Request $request): View`

- Triggered by `GET sales/reports/aging`
- Accepts `customer_id` and `branch_id` query parameters.
- If any filter is present, calls `$this->salesReportService->getAgingReport($filters)`.
- Returns view `dashboard.admin.sales.reports.aging` with `rows` (invoices bucketed into aging bands: current, 1–30, 31–60, 61–90, over 90 days overdue) and filter options.

#### `outstanding(Request $request): View`

- Triggered by `GET sales/reports/outstanding`
- Accepts `customer_id`, `branch_id`, `date_from`, `date_to`.
- Always calls `$this->salesReportService->getOutstandingInvoices($filters)` — returns paginated confirmed invoices with `payment_status != paid`.
- Returns view `dashboard.admin.sales.reports.outstanding`.

#### `invoiceRegister(Request $request): View`

- Triggered by `GET sales/reports/invoice-register`
- Accepts `customer_id`, `branch_id`, `status`, `date_from`, `date_to`.
- If any filter is set, calls `$this->salesReportService->getInvoiceRegister($filters)`.
- Returns view `dashboard.admin.sales.reports.invoice-register` with rows including balance per invoice.

#### `paymentRegister(Request $request): View`

- Triggered by `GET sales/reports/payment-register`
- Accepts `customer_id`, `branch_id`, `date_from`, `date_to`.
- If any filter is set, calls `$this->salesReportService->getPaymentRegister($filters)` — only `posted` payments.
- Returns view `dashboard.admin.sales.reports.payment-register`.

---

## 3. Services

### 3.1 SalesTaxRateService

File: `app/Services/Sales/Setup/SalesTaxRateService.php`

#### `getAll(int $perPage = 15): LengthAwarePaginator`

Queries `sales_tax_rates` with `taxPayableAccount` eager-loaded, ordered by `created_at` desc. Returns paginated result.

#### `getActive(): Collection`

Returns all tax rates where `is_active = true`, ordered by `code`.

#### `getDefault(): ?SalesTaxRate`

Returns the single tax rate where `is_default = true`, or null.

#### `getAccounts(): Collection`

Returns all `Finance\Account` records where `is_postable = true` and `is_active = true`, ordered by `code`. Used to populate the GL account dropdown when creating/editing a tax rate.

#### `create(array $data): SalesTaxRate`

1. If `$data['is_default']` is truthy, sets all existing tax rates' `is_default` to `false` (ensures only one default).
2. Appends `created_by = auth('admin')->id()` to `$data`.
3. Creates and returns the new `SalesTaxRate`.

#### `update(SalesTaxRate $rate, array $data): bool`

1. If `$data['is_default']` is truthy, clears `is_default` on all other records.
2. Appends `updated_by`.
3. Calls `$rate->update($data)`.

#### `delete(SalesTaxRate $rate): bool`

Calls `$rate->delete()`. No soft delete — permanent removal.

---

### 3.2 CustomerService

File: `app/Services/Sales/CustomerService.php`

#### `getAll(int $perPage = 15): LengthAwarePaginator`

Returns paginated customers with `branch` eager-loaded, ordered by `created_at` desc.

#### `getActive(): Collection`

Returns customers scoped by the `active` scope (`is_active = true`), ordered by `code`.

#### `getFormOptions(): array`

Returns:
- `branches`: active branches ordered by name.
- `accounts`: active postable finance accounts ordered by code (used for the customer's AR account assignment).

#### `getBranches(): Collection`

Returns active branches ordered by name.

#### `create(array $data): Customer`

Appends `created_by = auth('admin')->id()` then calls `Customer::create($data)`. Returns the new customer.

#### `update(Customer $customer, array $data): bool`

Appends `updated_by`, calls `$customer->update($data)`. Returns boolean.

#### `delete(Customer $customer): bool`

Calls `$customer->delete()`. Because `Customer` uses `SoftDeletes`, this is a soft delete.

---

### 3.3 QuotationService

File: `app/Services/Sales/QuotationService.php`  
Constructor injects: `NumberSequenceService $numberSequenceService`

#### `getAll(int $perPage = 15): LengthAwarePaginator`

Returns paginated `SalesQuotation` with `customer` and `branch` eager-loaded, ordered by `quotation_date` desc then `id` desc.

#### `getFormOptions(): array`

Returns `customers` (active, ordered by code), `items` (active, ordered by code), `branches` (active, ordered by name).

#### `create(array $data): SalesQuotation`

Runs inside `DB::transaction`. Steps:
1. Generates the quotation number via `generateQuotationNumber($branchId, $data['quotation_date'])`.
2. Creates the `SalesQuotation` record with status `draft`.
3. Calls `syncLines($quotation, $data['lines'])`.
4. Returns the quotation.

#### `update(SalesQuotation $quotation, array $data): SalesQuotation`

Runs inside `DB::transaction`. Updates header fields (date, expiry, customer, branch, notes), then calls `syncLines`. Returns updated quotation.

#### `send(SalesQuotation $quotation): void`

Guards: throws `RuntimeException('Only draft quotations can be sent.')` if not `isDraft()`.  
Sets `status = sent`.

#### `accept(SalesQuotation $quotation): void`

Guards: throws if not `isSent()`.  
Sets `status = accepted`.

#### `reject(SalesQuotation $quotation): void`

Guards: throws if not `isSent()`.  
Sets `status = rejected`.

#### `convertToOrder(SalesQuotation $quotation): SalesOrder`

Guards: throws if not `isAccepted()`.  
Runs inside `DB::transaction`:
1. Loads `lines`.
2. Generates a new order number via `NumberSequenceService` for the `sales_orders` sequence.
3. Creates a `SalesOrder` with status `draft`, copying `customer_id`, `branch_id`, and `notes`.
4. Iterates over quotation lines, creating corresponding `SalesOrderLine` records (copies `item_id`, `description`, `quantity`, `unit_price`, `line_total`). Note that lines from quotation-conversion do not have tax rate or discount fields set — those are plain copies of quotation line totals.
5. Updates quotation: sets `converted_to_order_id = $order->id`.
6. Returns the new `SalesOrder`.

#### `delete(SalesQuotation $quotation): bool`

Guards: throws if not `isDraft()`.  
Runs inside `DB::transaction`: deletes all lines then deletes the quotation.

#### `private syncLines(SalesQuotation $quotation, array $lines): void`

1. Deletes all existing lines for the quotation.
2. Iterates each line: computes `line_total = qty * unit_price`, rounded to 2 decimals.
3. Bulk-inserts all line records using `$quotation->lines()->insert($records)`.
4. Updates `total_amount` on the quotation header with the sum of all `line_total` values.

#### `private generateQuotationNumber(?int $branchId, string $date): string`

Uses `NumberSequenceService::getOrCreate('sales', 'quotations', $branchId, $year)` to get or create the sequence, increments it, pads to the configured width. Returns prefix (default `QT-`) + padded number.

---

### 3.4 SalesOrderService

File: `app/Services/Sales/SalesOrderService.php`  
Constructor injects: `NumberSequenceService $numberSequenceService`

#### `getAll(int $perPage = 15): LengthAwarePaginator`

Returns paginated `SalesOrder` with `customer` and `branch`, ordered by `order_date` desc then `id` desc.

#### `getFormOptions(): array`

Returns `customers`, `items`, `branches`, `taxRates`, `quotations` (with customer eager-loaded).

#### `create(array $data): SalesOrder`

Runs inside `DB::transaction`:
1. `getActiveCustomer($data['customer_id'])` — throws `ValidationException` if customer not found or inactive.
2. `prepareLines($data['lines'])` — see below.
3. `calculateTotals($preparedLines, $data)` — see below.
4. Generates order number via `generateOrderNumber`.
5. Creates `SalesOrder` with all total fields and status `draft`.
6. Creates line records via `$salesOrder->lines()->createMany($lines)`.
7. Returns the order with relationships eager-loaded.

#### `update(SalesOrder $salesOrder, array $data): SalesOrder`

Runs inside `DB::transaction`. Validates customer, re-prepares lines, recalculates totals, updates header fields, deletes old lines, creates new lines. Returns updated order.

#### `confirm(SalesOrder $salesOrder): void`

Guards: throws if not `isDraft()`. Sets `status = confirmed`, `confirmed_at = now()`.

#### `cancel(SalesOrder $salesOrder): void`

Guards: throws if already `cancelled`. Sets `status = cancelled`.

#### `delete(SalesOrder $salesOrder): bool`

Guards: throws if not `isDraft()`. Deletes lines then order in transaction.

#### `private prepareLines(array $lines): array`

Iterates each input line:
1. Validates item is active via `getActiveItem`.
2. `line_subtotal = qty * unit_price` (rounded to 2dp).
3. `discount_amount = line_subtotal * (discount_percent / 100)` (rounded).
4. `after_discount = line_subtotal - discount_amount`.
5. If `tax_rate_id` is provided, looks up the `SalesTaxRate` and computes `tax_amount = after_discount * (rate / 100)`.
6. `line_total = after_discount + tax_amount`.
7. Returns array of prepared line arrays.

#### `private calculateTotals(array $preparedLines, array $data): array`

1. `subtotal = sum(line_subtotal)` across all lines.
2. `line_tax = sum(tax_amount)` across all lines.
3. `line_disc = sum(discount_amount)` across all lines.
4. Computes header-level discount from `discount_type` and `discount_value` (percent of subtotal or fixed amount).
5. `total_discount = line_disc + header_disc`.
6. `total_amount = subtotal - header_disc + line_tax` (header discount reduces the taxable base, line taxes are added back).
7. Returns `subtotal`, `discount_amount` (total), `tax_amount` (line taxes only), `total_amount`.

#### `private generateOrderNumber(?int $branchId, string $orderDate): string`

Uses sequence key `sales_orders` under module `sales`. Returns prefix + zero-padded number.

---

### 3.5 InvoiceService

File: `app/Services/Sales/InvoiceService.php`  
Constructor injects: `NumberSequenceService $numberSequenceService`

#### `getAll(int $perPage = 15): LengthAwarePaginator`

Returns paginated invoices with `customer`, `branch`, `salesOrder`, ordered by `invoice_date` desc then `id` desc.

#### `getFormOptions(): array`

Returns `customers`, `salesOrders`, `items`, `branches`, `taxRates`.

#### `create(array $data): Invoice`

Runs inside `DB::transaction`:
1. Validates customer is active.
2. `resolveSalesOrder($data['sales_order_id'])` — returns `SalesOrder` or null if not provided.
3. `prepareLines($data['lines'])` — similar to `SalesOrderService::prepareLines`.
4. `calculateTotals($preparedLines)` — computes `subtotal`, `tax_amount`, `total_amount` (no header-level discount on invoices).
5. `generateInvoiceNumber($branchId, $invoiceDate)`.
6. Creates `Invoice` with status `draft`, `paid_amount = 0`, `payment_status = unpaid`.
7. Creates lines.
8. Returns invoice with relationships.

#### `update(Invoice $invoice, array $data): Invoice`

Recalculates everything, deletes old lines, creates new lines. Does not change status.

#### `confirm(Invoice $invoice): void`

Guards: throws if not `draft`. Sets status to `confirmed`.

#### `cancel(Invoice $invoice): void`

Guards: throws if already `cancelled`. Sets status to `cancelled`.

#### `delete(Invoice $invoice): bool`

Guards: throws if not `draft`. Deletes lines and header.

#### `private prepareLines(array $lines): array`

Same structure as `SalesOrderService::prepareLines`:
- `line_subtotal = qty * unit_price`
- `discount_amount = line_subtotal * (discount_percent / 100)`
- `after_disc = line_subtotal - discount_amount`
- `tax_amount = after_disc * (tax_rate.rate / 100)` if tax rate assigned
- `line_total = after_disc + tax_amount`

#### `private calculateTotals(array $preparedLines): array`

- `subtotal = sum(line_subtotal)`
- `tax_amount = sum(tax_amount)`
- `disc_amt = sum(discount_amount)`
- `total_amount = subtotal - disc_amt + tax_amount`

Note: on invoices there is no separate header-level discount. All discount is at the line level.

#### `private normalizeRate($value): ?float`

Returns `null` if value is null or empty string, otherwise `round((float) $value, 6)`. Used for `exchange_rate`.

#### `private generateInvoiceNumber(?int $branchId, string $invoiceDate): string`

Uses sequence key `invoices` under module `sales`. Returns prefix + padded number.

---

### 3.6 SalesPaymentService

File: `app/Services/Sales/SalesPaymentService.php`  
Constructor injects: `NumberSequenceService $numberSequenceService`

#### `getAll(int $perPage = 15): LengthAwarePaginator`

Returns paginated `SalesPayment` with `customer` and `branch`, ordered by `payment_date` desc then `id` desc.

#### `getFormOptions(): array`

Returns:
- `customers`: active customers ordered by code.
- `branches`: active branches ordered by name.
- `methods`: hardcoded array `['cash', 'bank_transfer', 'check', 'other']`.

#### `getCustomerInvoices(int $customerId): Collection`

Returns all `Invoice` records for the customer where:
- `payment_status` is `unpaid` or `partially_paid`
- `status = confirmed`

Ordered by `invoice_date`. This is used by the AJAX endpoint and the `customerInvoices` controller action.

#### `create(array $data): SalesPayment`

Runs inside `DB::transaction`:
1. Generates payment number via `generatePaymentNumber`.
2. Creates `SalesPayment` with status `draft`.
3. Calls `syncAllocations($payment, $data['allocations'])` to attach invoice allocations.
4. Returns payment with `customer`, `branch`, `invoices` eager-loaded.

#### `post(SalesPayment $payment): void`

Guards: throws if not `draft`.  
Runs inside `DB::transaction`:
1. Loads `invoices` (with pivot).
2. For each linked invoice, calls `applyToInvoice($invoice, $applied_amount)`.
3. Sets payment status to `posted`.

#### `reverse(SalesPayment $payment): SalesPayment`

Guards: throws if not `posted`.  
Runs inside `DB::transaction`:
1. Loads linked invoices.
2. For each invoice, calls `reverseFromInvoice($invoice, $applied_amount)` — reduces `paid_amount` and recalculates `payment_status`.
3. Creates a new `SalesPayment` with `amount = -original_amount`, status `posted`, `reversed_from_id = $payment->id`.
4. Sets original payment status to `reversed`.
5. Returns the new reversal payment.

#### `delete(SalesPayment $payment): bool`

Guards: throws if not `draft`. Detaches invoice allocations then deletes payment.

#### `private syncAllocations(SalesPayment $payment, array $allocations): void`

Detaches all existing invoice attachments then re-attaches allocations from the input array. Each allocation must have `invoice_id` and `applied_amount > 0`. Attaches via `$payment->invoices()->attach($sync)` with `applied_amount` as pivot data.

#### `private applyToInvoice(Invoice $invoice, float $amount): void`

Adds `$amount` to `invoice.paid_amount`. Sets `payment_status`:
- `paid` if `new_paid >= total_amount`
- `partially_paid` if `new_paid > 0`
- `unpaid` if `new_paid == 0`

#### `private reverseFromInvoice(Invoice $invoice, float $amount): void`

Subtracts `$amount` from `invoice.paid_amount` (floor at 0). Recalculates `payment_status` using the same three-way logic.

#### `private generatePaymentNumber(?int $branchId, string $date): string`

Uses sequence key `payments` under module `sales`. Prefix defaults to `PAY-` if no prefix configured.

---

### 3.7 SalesReturnService

File: `app/Services/Sales/SalesReturnService.php`  
Constructor injects: `NumberSequenceService`, `StockBalanceService`, `SalesPostingService`

#### `getAll(int $perPage = 15): LengthAwarePaginator`

Returns paginated `SalesReturn` with `customer` and `originalInvoice`, ordered by `return_date` desc then `id` desc.

#### `getFormOptions(): array`

Returns `customers`, `branches`, `warehouses` (all active).

#### `getCustomerInvoices(int $customerId): Collection`

Returns all `confirmed` invoices for the customer, ordered by `invoice_date`. (Unlike the payment flow, does not filter on `payment_status` — any confirmed invoice can be the basis for a return.)

#### `getInvoiceLines(int $invoiceId): Collection`

Returns all `InvoiceLine` records for the invoice with `item` eager-loaded. Used by the AJAX endpoint to populate the return line form.

#### `create(array $data): SalesReturn`

Runs inside `DB::transaction`:
1. `prepareLines($data['lines'])` — skips lines with `quantity <= 0`.
2. `calculateTotals($lines)`.
3. Generates return number.
4. Creates `SalesReturn` with status `draft`. If `restocked = false`, sets `warehouse_id = null`.
5. Creates lines.
6. Returns return with relationships.

#### `post(SalesReturn $return): void`

Guards: throws if not `draft`.  
Runs inside `DB::transaction`:
1. Loads `lines.item` and `originalInvoice`.
2. Calls `validateReturnQuantities($return)` — checks that each line's quantity does not exceed the original invoice line quantity minus any previously posted return quantities for the same invoice line.
3. For each line where `$return->restocked && $return->warehouse_id && $line->restocked && $line->item->type === 'stock'`:
   a. Computes `cogs_amount = quantity * item.average_cost`.
   b. Updates `line.cogs_amount`.
   c. Creates a `StockMovement` record with `movement_type = in`.
   d. Calls `StockBalanceService::applyMovement($item_id, $warehouse_id, 'in', quantity, avg_cost)` to update the stock balance.
4. Calls `$this->salesPosting->postReturn($return)` — creates GL entries if finance is configured. Silently skipped (returns null) if `sales_journal_id` is not set.
5. Increases `original_invoice.paid_amount` by `return.total_amount`, recalculates `payment_status` on the original invoice.
6. Sets `return.status = posted`, `posted_at = now()`.

#### `delete(SalesReturn $return): bool`

Guards: throws if not `draft`. Deletes lines then return in transaction.

#### `private validateReturnQuantities(SalesReturn $return): void`

For each line that has an `original_invoice_line_id`:
1. Finds the original `InvoiceLine`.
2. Sums `quantity` from all other `SalesReturnLine` records pointing to the same `original_invoice_line_id` where the parent return has status not `draft` (i.e., already posted).
3. Computes `returnable = original_qty - already_returned`.
4. Throws `RuntimeException` if `line.quantity > returnable`.

#### `private prepareLines(array $lines): array`

For each input line:
- `qty = max(0, line.quantity)`
- `price = max(0, line.unit_price)`
- `tax_amount = max(0, line.tax_amount)`
- `line_total = qty * price + tax_amount`
- Skips lines where `qty <= 0`.
- Carries over `original_invoice_line_id` and per-line `restocked` flag.

#### `private calculateTotals(array $lines): array`

- `subtotal = sum(quantity * unit_price)`
- `tax_amount = sum(tax_amount)`
- `total_amount = subtotal + tax_amount`

#### `private generateReturnNumber(?int $branchId, string $date): string`

Uses sequence key `returns` under module `sales`. Prefix defaults to `RET-`.

---

### 3.8 SalesPostingService

File: `app/Services/Sales/Finance/SalesPostingService.php`  
Constructor injects: `JournalEntryService`, `JournalPostingService`, `FiscalPeriodService`, `StockBalanceService`

This service bridges the Sales module with the Finance GL. It uses the draft→post path so that `account_period_balances` (trial balance) is updated.

#### `postInvoice(Invoice $invoice): JournalEntry`

Guards: throws if invoice is not `confirmed`. Throws if `sales_journal_id` is not set in `Settings`.

Runs inside `DB::transaction`:
1. Loads `lines.item.category`, `lines.taxRate`, `customer`.
2. Iterates each invoice line:
   - Computes net revenue = `line_subtotal - discount_amount`.
   - Resolves revenue account (item → item.revenue_account_id → item.category.revenue_account_id → setting `default_revenue_account_id`).
   - If `tax_amount > 0`: resolves tax payable account (taxRate → taxRate.tax_payable_account_id → setting `default_tax_payable_account_id`).
   - If item type is `stock`: calls `deductStockForLine` (see below) which creates a `StockMovement` and accumulates COGS/Inventory debit/credit amounts.
3. Assembles journal lines:
   - DR Accounts Receivable (resolved from `customer.ar_account_id` or `default_ar_account_id`) for `invoice.total_amount`.
   - CR Revenue accounts (grouped by account ID) for net revenue amounts.
   - CR Tax Payable accounts (grouped) for tax amounts.
   - DR COGS accounts (grouped) for COGS amounts.
   - CR Inventory accounts (grouped) for inventory reduction amounts.
4. Calls `assertBalanced` — throws `RuntimeException` if total debits ≠ total credits (tolerance 0.01).
5. Calls `postLines` to create and immediately post the journal entry.
6. Updates `invoice.journal_entry_id`, `invoice.status = posted`, `invoice.posted_at`.
7. Returns the `JournalEntry`.

#### `postReturn(SalesReturn $return): ?JournalEntry`

Returns `null` silently if `sales_journal_id` is not configured (allows returns to work without finance integration).

When configured:
1. Loads `lines.item.category`, `lines.originalInvoiceLine.taxRate`, `customer`.
2. Iterates each return line:
   - Net = `quantity * unit_price` (DR Revenue to reverse it).
   - If `tax_amount > 0`: resolves tax account from the original invoice line's tax rate → DR Tax Payable (reversal).
   - If restocked and item is stock type and `cogs_amount > 0`: DR Inventory, CR COGS (restocking reversal).
3. Assembles journal lines:
   - DR Revenue (reversal of original revenue).
   - DR Tax Payable (reversal).
   - CR Accounts Receivable for `return.total_amount`.
   - DR Inventory (if restocked).
   - CR COGS (if restocked).
4. Asserts balanced, posts the entry.
5. Updates `return.journal_entry_id`.

#### `private deductStockForLine(Invoice $invoice, InvoiceLine $line, ...): void`

1. Resolves the warehouse for the line: `line.warehouse_id` → setting `default_sales_warehouse_id` → single active warehouse for the branch → throws if ambiguous.
2. Calls `StockBalanceService::applyMovement($item_id, $warehouse_id, 'out', qty)` which deducts stock and returns `cogs_amount` and `average_cost_before`.
3. Creates a `StockMovement` record referencing the invoice.
4. Updates `line.cogs_amount` and `line.warehouse_id`.
5. Accumulates amounts in `$cogsByAccount` and `$inventoryByAccount` maps.

#### `private postLines(int $journalId, $date, ?int $branchId, string $description, array $lines): JournalEntry`

1. Throws if `$branchId` is null.
2. Resolves `FiscalPeriod` for the date via `FiscalPeriodService::findByDate` — throws if none found.
3. Calls `JournalEntryService::create(...)` to create the entry in `draft` status.
4. Calls `JournalPostingService::post($entry, $adminId)` to post it immediately (updates `account_period_balances`).
5. Returns the posted `JournalEntry`.

#### Account resolution helpers (private)

| Method | Resolution chain |
|--------|-----------------|
| `resolveArAccount` | `customer.ar_account_id` → setting `default_ar_account_id` → throws |
| `resolveRevenueAccount` | `item.revenue_account_id` → `item.category.revenue_account_id` → setting `default_revenue_account_id` → throws |
| `resolveTaxAccount` | `taxRate.tax_payable_account_id` → setting `default_tax_payable_account_id` → throws |
| `resolveCogsAccount` | `item.cogs_account_id` → `item.category.cogs_account_id` → setting `default_cogs_account_id` → throws |
| `resolveInventoryAccount` | `item.inventory_account_id` → `item.category.inventory_account_id` → setting `default_inventory_account_id` → throws |

---

### 3.9 SalesReportService

File: `app/Services/Sales/Reports/SalesReportService.php`

#### `getFilterOptions(): array`

Returns `branches` (active) and `customers` (active) for filter dropdowns.

#### `getReport(array $filters): array`

Queries `invoices` joined to `customers` and `branches` where `status = confirmed`. Applies optional `branch_id`, `customer_id`, `date_from`, `date_to` filters. Returns:
- `rows`: mapped collection of invoice summary objects (invoice_number, invoice_date, customer_name, branch_name, subtotal, tax_amount, total_amount, status).
- `totals`: count, sum of subtotal, sum of tax_amount, sum of total_amount.

#### `getAgingReport(array $filters = []): Collection`

Queries confirmed invoices where `payment_status != paid`. Uses raw SQL to compute `days_overdue = DATEDIFF(CURDATE(), due_date)` and assigns `aging_bucket`:
- `current`: 0 or negative days overdue
- `1_30`: 1–30 days
- `31_60`: 31–60 days
- `61_90`: 61–90 days
- `over_90`: more than 90 days

Returns mapped collection with balance = `total_amount - paid_amount`.

#### `getOutstandingInvoices(array $filters = []): LengthAwarePaginator`

Returns paginated confirmed invoices where `payment_status != paid`. Supports `customer_id`, `branch_id`, `date_from`, `date_to` filters. Includes `customer` and `branch` relationships.

#### `getInvoiceRegister(array $filters = []): Collection`

Queries all invoices (any status) joined to customers and branches. Supports `customer_id`, `branch_id`, `status`, `date_from`, `date_to` filters. Returns collection including `paid_amount`, `balance`, and `payment_status` per invoice.

#### `getPaymentRegister(array $filters = []): Collection`

Queries `sales_payments` where `status = posted`, joined to customers and branches. Supports `customer_id`, `branch_id`, `date_from`, `date_to` filters. Returns payment summary rows.

#### `private translateValue(mixed $value): string`

Decodes a JSON-stored translatable field (e.g., customer name stored as `{"en":"Acme","ar":"..."}`) into the current app locale string. Falls back to `en`, then `ar`, then the first available translation.

---

## 4. Models

### 4.1 SalesTaxRate

File: `app/Models/Sales/Setup/SalesTaxRate.php`  
Table: `sales_tax_rates`  
Traits: `HasFactory`, `HasTranslations` (Spatie)

**Fillable fields:**

| Column | Type | Description |
|--------|------|-------------|
| `code` | string UNIQUE | Short code (e.g., `VAT15`) |
| `name` | json | Translatable display name |
| `rate` | decimal(8,4) | Tax percentage (e.g., `15.0000`) |
| `tax_payable_account_id` | FK → accounts | GL account for tax payable |
| `is_active` | boolean | Whether this rate can be selected |
| `is_default` | boolean | Whether this is the system default rate |
| `created_by` | FK → admins | |
| `updated_by` | FK → admins | |

**Translatable:** `name`

**Casts:** `rate` → `decimal:4`, `is_active` → `boolean`, `is_default` → `boolean`

**Relationships:**
- `taxPayableAccount(): BelongsTo Account`
- `creator(): BelongsTo Admin`
- `updater(): BelongsTo Admin`

**Scopes:**
- `scopeActive($query)`: filters `is_active = true`
- `scopeDefault($query)`: filters `is_default = true`

---

### 4.2 Customer

File: `app/Models/Sales/Customer.php`  
Table: `customers`  
Traits: `HasFactory`, `HasTranslations`, `SoftDeletes`

**Fillable fields:**

| Column | Type | Description |
|--------|------|-------------|
| `code` | string UNIQUE | Customer reference code |
| `name` | json | Translatable name |
| `phone` | string nullable | |
| `email` | string nullable | |
| `address` | json nullable | Translatable address |
| `tax_number` | string nullable | VAT/tax registration number |
| `branch_id` | FK → branches nullable | Associated branch |
| `is_active` | boolean | Whether customer can be used in transactions |
| `credit_limit` | decimal(18,2) | Maximum credit allowed |
| `payment_terms_days` | unsignedInt | Default payment terms in days |
| `ar_account_id` | FK → accounts nullable | Customer-specific AR GL account |
| `opening_balance` | decimal(18,2) | Opening balance for migration purposes |
| `opening_balance_date` | date nullable | |
| `currency_code` | char(3) | Default currency (e.g., `USD`) |
| `created_by` | FK → admins | |
| `updated_by` | FK → admins | |

**Translatable:** `name`, `address`

**Casts:** `is_active` → `boolean`, `credit_limit` → `decimal:2`, `opening_balance` → `decimal:2`, `opening_balance_date` → `date`

**Relationships:**
- `branch(): BelongsTo Branch`
- `arAccount(): BelongsTo Account` (FK `ar_account_id`)
- `creator(): BelongsTo Admin`
- `updater(): BelongsTo Admin`

**Scopes:**
- `scopeActive($query)`: filters `is_active = true`

**Soft Deletes:** yes — `deleted_at` column.

---

### 4.3 SalesQuotation

File: `app/Models/Sales/SalesQuotation.php`  
Table: `sales_quotations`  
Traits: `HasFactory`, `HasTranslations`

**Fillable fields:**

| Column | Type | Description |
|--------|------|-------------|
| `quotation_number` | string UNIQUE | Auto-generated (e.g., `QT-0001`) |
| `quotation_date` | date | |
| `expiry_date` | date nullable | |
| `customer_id` | FK → customers | |
| `branch_id` | FK → branches nullable | |
| `status` | enum | `draft`, `sent`, `accepted`, `rejected`, `expired` |
| `total_amount` | decimal(18,2) | Sum of all line totals |
| `notes` | json nullable | Translatable |
| `converted_to_order_id` | FK → sales_orders nullable | Set when converted |
| `created_by` | FK → admins | |
| `updated_by` | FK → admins | |

**Translatable:** `notes`

**Casts:** `quotation_date` → `date`, `expiry_date` → `date`, `total_amount` → `decimal:2`

**Relationships:**
- `customer(): BelongsTo Customer`
- `branch(): BelongsTo Branch`
- `lines(): HasMany SalesQuotationLine` (ordered by id)
- `convertedOrder(): BelongsTo SalesOrder` (FK `converted_to_order_id`)
- `creator(): BelongsTo Admin`
- `updater(): BelongsTo Admin`

**Status helpers:**
- `isDraft()`, `isSent()`, `isAccepted()`, `isEditable()` (only draft), `isExpired()` (status=expired or sent+expiry_date in past)

---

### 4.4 SalesQuotationLine

File: `app/Models/Sales/SalesQuotationLine.php`  
Table: `sales_quotation_lines`  
Traits: `HasFactory`, `HasTranslations`

**Fillable fields:**

| Column | Type | Description |
|--------|------|-------------|
| `sales_quotation_id` | FK → sales_quotations | Cascade on delete |
| `item_id` | FK → items | |
| `description` | json nullable | Translatable line description |
| `quantity` | decimal(18,4) | |
| `unit_price` | decimal(18,4) | |
| `line_total` | decimal(18,2) | qty × unit_price |

**Note:** Quotation lines do not have discount or tax fields — those are added at the sales order / invoice level.

**Translatable:** `description`

**Relationships:**
- `quotation(): BelongsTo SalesQuotation`
- `item(): BelongsTo Item`

---

### 4.5 SalesOrder

File: `app/Models/Sales/SalesOrder.php`  
Table: `sales_orders`  
Traits: `HasFactory`, `HasTranslations`

**Fillable fields:**

| Column | Type | Description |
|--------|------|-------------|
| `order_number` | string UNIQUE | Auto-generated |
| `order_date` | date | |
| `delivery_date` | date nullable | |
| `confirmed_at` | datetime nullable | Set on confirm action |
| `customer_id` | FK → customers | |
| `branch_id` | FK → branches nullable | |
| `quotation_id` | FK → sales_quotations nullable | Source quotation if converted |
| `status` | string | `draft`, `confirmed`, `cancelled` |
| `notes` | json nullable | Translatable |
| `discount_type` | enum | `percent` or `amount` |
| `discount_value` | decimal(18,2) | The raw discount input value |
| `discount_amount` | decimal(18,2) | Computed discount in currency |
| `subtotal` | decimal(18,2) | Sum of line subtotals before discount/tax |
| `tax_amount` | decimal(18,2) | Sum of line tax amounts |
| `total_amount` | decimal(18,2) | Final amount |
| `invoiced_amount` | decimal(18,2) | Amount covered by invoices (for partial invoicing tracking) |
| `created_by` | FK → admins | |
| `updated_by` | FK → admins | |

**Translatable:** `notes`

**Relationships:**
- `customer(): BelongsTo Customer`
- `branch(): BelongsTo Branch`
- `quotation(): BelongsTo SalesQuotation` (FK `quotation_id`)
- `lines(): HasMany SalesOrderLine` (ordered by id)
- `creator(): BelongsTo Admin`
- `updater(): BelongsTo Admin`

**Status helpers:** `isDraft()`, `isConfirmed()`, `isCancelled()`, `isEditable()` (only draft)

---

### 4.6 SalesOrderLine

File: `app/Models/Sales/SalesOrderLine.php`  
Table: `sales_order_lines`  
Traits: `HasFactory`, `HasTranslations`

**Fillable fields:**

| Column | Type | Description |
|--------|------|-------------|
| `sales_order_id` | FK → sales_orders | Cascade on delete |
| `item_id` | FK → items | |
| `description` | json nullable | Translatable |
| `quantity` | decimal(18,4) | |
| `unit_price` | decimal(18,4) | |
| `line_subtotal` | decimal(18,2) | qty × unit_price |
| `discount_percent` | decimal(5,2) | Per-line discount percentage |
| `discount_amount` | decimal(18,2) | Computed per-line discount |
| `tax_rate_id` | FK → sales_tax_rates nullable | |
| `tax_amount` | decimal(18,2) | Computed tax for this line |
| `line_total` | decimal(18,2) | After discount and tax |
| `invoiced_quantity` | decimal(18,4) | Tracks how much of this line has been invoiced |

**Relationships:**
- `order(): BelongsTo SalesOrder`
- `item(): BelongsTo Item`
- `taxRate(): BelongsTo SalesTaxRate`

---

### 4.7 Invoice

File: `app/Models/Sales/Invoice.php`  
Table: `invoices`  
Traits: `HasFactory`, `HasTranslations`

**Fillable fields:**

| Column | Type | Description |
|--------|------|-------------|
| `invoice_number` | string UNIQUE | Auto-generated |
| `invoice_date` | date | |
| `due_date` | date nullable | |
| `customer_id` | FK → customers | |
| `sales_order_id` | FK → sales_orders nullable | |
| `branch_id` | FK → branches nullable | |
| `currency_code` | string nullable | Foreign currency code |
| `exchange_rate` | decimal(18,6) nullable | Rate at time of invoice |
| `subtotal` | decimal(18,2) | Sum of line subtotals |
| `tax_amount` | decimal(18,2) | Sum of line taxes |
| `total_amount` | decimal(18,2) | Net total (subtotal - discounts + taxes) |
| `status` | string | `draft`, `confirmed`, `posted`, `cancelled` |
| `paid_amount` | decimal(15,2) | Running total of applied payments + return credits |
| `payment_status` | enum | `unpaid`, `partially_paid`, `paid` |
| `journal_entry_id` | FK → journal_entries nullable | Set after GL posting |
| `posted_at` | datetime nullable | |
| `notes` | json nullable | Translatable |
| `created_by` | FK → admins | |
| `updated_by` | FK → admins | |

**Translatable:** `notes`

**Casts:** `invoice_date`, `due_date` → `date`, `posted_at` → `datetime`, `exchange_rate` → `decimal:6`, `subtotal`, `tax_amount`, `total_amount`, `paid_amount` → `decimal:2`

**Relationships:**
- `customer(): BelongsTo Customer`
- `salesOrder(): BelongsTo SalesOrder`
- `branch(): BelongsTo Branch`
- `lines(): HasMany InvoiceLine` (ordered by id)
- `journalEntry(): BelongsTo JournalEntry`
- `creator(): BelongsTo Admin`
- `updater(): BelongsTo Admin`

**Status helpers:** `isDraft()`, `isConfirmed()`, `isPosted()`, `isCancelled()`, `isEditable()` (only draft)

**Other helpers:**
- `remainingBalance(): float` — `max(0, total_amount - paid_amount)`
- `isFullyPaid(): bool` — `payment_status === 'paid'`

---

### 4.8 InvoiceLine

File: `app/Models/Sales/InvoiceLine.php`  
Table: `invoice_lines`  
Traits: `HasFactory`, `HasTranslations`

**Fillable fields:**

| Column | Type | Description |
|--------|------|-------------|
| `invoice_id` | FK → invoices | Cascade on delete |
| `item_id` | FK → items | |
| `description` | json nullable | Translatable |
| `quantity` | decimal(18,4) | |
| `unit_price` | decimal(18,4) | |
| `line_subtotal` | decimal(18,2) | qty × price |
| `discount_percent` | decimal(5,2) | |
| `discount_amount` | decimal(18,2) | Computed discount |
| `tax_rate_id` | FK → sales_tax_rates nullable | |
| `tax_amount` | decimal(18,2) | |
| `line_total` | decimal(18,2) | Net (after discount + tax) |
| `cogs_amount` | decimal(18,2) | Populated on GL posting (cost of goods sold) |
| `warehouse_id` | FK → warehouses nullable | Resolved on GL posting |

**Relationships:**
- `invoice(): BelongsTo Invoice`
- `item(): BelongsTo Item`
- `taxRate(): BelongsTo SalesTaxRate`
- `warehouse(): BelongsTo Warehouse`

---

### 4.9 SalesPayment

File: `app/Models/Sales/SalesPayment.php`  
Table: `sales_payments`  
Traits: `HasTranslations`

**Fillable fields:**

| Column | Type | Description |
|--------|------|-------------|
| `payment_number` | string UNIQUE | Auto-generated (prefix `PAY-`) |
| `payment_date` | date | |
| `customer_id` | FK → customers | |
| `branch_id` | FK → branches nullable | |
| `amount` | decimal(18,2) | Total payment amount (negative for reversals) |
| `payment_method` | enum | `cash`, `bank_transfer`, `check`, `other` |
| `reference` | string nullable | External reference |
| `notes` | json nullable | Translatable |
| `status` | enum | `draft`, `posted`, `reversed` |
| `reversed_from_id` | FK → sales_payments nullable | Points to original payment for reversals |
| `created_by` | FK → admins | |
| `updated_by` | FK → admins | |

**Pivot table:** `sales_payment_invoices` (`sales_payment_id`, `invoice_id`, `applied_amount`)

**Casts:** `payment_date` → `date`, `amount` → `decimal:2`

**Relationships:**
- `customer(): BelongsTo Customer`
- `branch(): BelongsTo Branch`
- `reversedFrom(): BelongsTo SalesPayment` (self-referential, FK `reversed_from_id`)
- `creator(): BelongsTo Admin`
- `updater(): BelongsTo Admin`
- `invoices(): BelongsToMany Invoice` via `sales_payment_invoices` with pivot `applied_amount`

**Status helpers:** `isDraft()`, `isPosted()`, `isReversed()`

---

### 4.10 SalesReturn

File: `app/Models/Sales/SalesReturn.php`  
Table: `sales_returns`  
Traits: `HasTranslations`

**Fillable fields:**

| Column | Type | Description |
|--------|------|-------------|
| `return_number` | string UNIQUE | Auto-generated (prefix `RET-`) |
| `return_date` | date | |
| `customer_id` | FK → customers | |
| `original_invoice_id` | FK → invoices | The invoice being credited |
| `branch_id` | FK → branches nullable | |
| `warehouse_id` | FK → warehouses nullable | Required if `restocked = true` |
| `reason` | text | Free-text return reason |
| `subtotal` | decimal(18,2) | Sum of line qty × price |
| `tax_amount` | decimal(18,2) | Sum of line taxes |
| `total_amount` | decimal(18,2) | subtotal + tax |
| `restocked` | boolean | Whether returned items go back into stock |
| `journal_entry_id` | FK → journal_entries nullable | Set after GL posting |
| `posted_at` | datetime nullable | |
| `status` | enum | `draft`, `posted`, `refunded` |
| `notes` | json nullable | Translatable |
| `created_by` | FK → admins | |
| `updated_by` | FK → admins | |

**Casts:** `return_date` → `date`, `posted_at` → `datetime`, `restocked` → `boolean`, monetary fields → `decimal:2`

**Relationships:**
- `customer(): BelongsTo Customer`
- `originalInvoice(): BelongsTo Invoice` (FK `original_invoice_id`)
- `branch(): BelongsTo Branch`
- `warehouse(): BelongsTo Warehouse`
- `lines(): HasMany SalesReturnLine` (ordered by id)
- `creator(): BelongsTo Admin`
- `updater(): BelongsTo Admin`

**Status helpers:** `isDraft()`, `isPosted()`

---

### 4.11 SalesReturnLine

File: `app/Models/Sales/SalesReturnLine.php`  
Table: `sales_return_lines`

**Fillable fields:**

| Column | Type | Description |
|--------|------|-------------|
| `sales_return_id` | FK → sales_returns | Cascade on delete |
| `original_invoice_line_id` | FK → invoice_lines nullable | Source line for quantity validation |
| `item_id` | FK → items | |
| `quantity` | decimal(18,2) | |
| `unit_price` | decimal(18,2) | Copied from original invoice line |
| `tax_amount` | decimal(18,2) | Copied from original invoice line |
| `line_total` | decimal(18,2) | qty × price + tax_amount |
| `cogs_amount` | decimal(18,2) | Populated on post if restocked |
| `restocked` | boolean | Per-line restock flag |

**Relationships:**
- `salesReturn(): BelongsTo SalesReturn`
- `originalInvoiceLine(): BelongsTo InvoiceLine` (FK `original_invoice_line_id`)
- `item(): BelongsTo Item`

---

## 5. Key Logic Explanations

### 5.1 Invoice Total and Tax Calculation

Invoice totals are calculated in `InvoiceService::prepareLines` and `InvoiceService::calculateTotals`. The computation is purely line-level on invoices (no header-level discount on invoices, unlike sales orders).

**Per line:**

```
line_subtotal = quantity × unit_price                   (rounded to 2dp)
discount_amount = line_subtotal × (discount_percent / 100)
after_discount = line_subtotal - discount_amount
tax_amount = after_discount × (tax_rate.rate / 100)     (0 if no tax rate)
line_total = after_discount + tax_amount
```

**Header totals:**

```
subtotal     = sum of all line_subtotal
tax_amount   = sum of all line tax_amount
disc_amount  = sum of all line discount_amount
total_amount = subtotal - disc_amount + tax_amount
```

**Example:**

A line for 10 units at 100.00 each with a 10% discount and a 15% VAT:
- `line_subtotal = 10 × 100.00 = 1000.00`
- `discount_amount = 1000.00 × 10% = 100.00`
- `after_discount = 900.00`
- `tax_amount = 900.00 × 15% = 135.00`
- `line_total = 900.00 + 135.00 = 1035.00`
- `total_amount = 1000.00 - 100.00 + 135.00 = 1035.00`

Sales orders use the same per-line calculation but also support a header-level discount (applied on top of line discounts against the subtotal):

```
total_amount = subtotal - header_discount + sum(line_tax)
```

The header discount does not reduce the tax base on orders — taxes are computed on after-line-discount amounts before the header discount is applied.

---

### 5.2 Quotation to Sales Order Conversion

The conversion flow is triggered by `POST /sales/quotations/{quotation}/convert` and handled by `QuotationService::convertToOrder`.

**Pre-conditions:**
- Quotation must be in `accepted` status. Any other status throws `RuntimeException`.

**Steps inside a DB transaction:**

1. A new `SalesOrder` is created with:
   - A freshly generated order number from the `sales_orders` number sequence.
   - `order_date = today` (not the quotation date).
   - Same `customer_id` and `branch_id` as the quotation.
   - Status `draft`.
   - Notes copied from the quotation (raw, bypassing translation cast).

2. Each `SalesQuotationLine` is copied to a `SalesOrderLine`:
   - `item_id`, `description` (raw), `quantity`, `unit_price`, `line_total` are copied.
   - `discount_percent`, `discount_amount`, `tax_rate_id`, `tax_amount` are NOT set — the order line gets these with default zero values. If the user wants discounts or taxes on the order, they must edit the order afterward.
   - The header totals (`subtotal`, `tax_amount`, `total_amount`) on the sales order are also zero until the order is edited and saved through the proper service, which recalculates everything.

3. The quotation is updated with `converted_to_order_id = $order->id`.

4. The quotation status is NOT automatically changed. The quotation remains `accepted` and the `converted_to_order_id` field serves as the indicator that conversion happened.

**After conversion:**
The user is redirected to the show page of the new sales order. To get correct totals, discounts, and taxes on the converted order, the user should edit it and save it, which triggers `SalesOrderService::update` and recalculates everything properly.

---

### 5.3 Sales Return: Stock and GL Effects

Sales returns have two independent effects: stock restoration and GL posting. Both are triggered by the `post` action and happen inside a single DB transaction.

#### Stock Effect

Stock restoration only applies when ALL of the following are true:
- The `SalesReturn` header has `restocked = true`
- The `SalesReturn` has a `warehouse_id`
- The individual `SalesReturnLine` has `restocked = true`
- The line's item has `type = 'stock'`

When these conditions are met:
1. `avg_cost = item.average_cost` (current weighted average)
2. `cogs_amount = quantity × avg_cost` (saved on the line)
3. A `StockMovement` record is created with `movement_type = in`, referencing `sales_return` and the return's ID.
4. `StockBalanceService::applyMovement($item_id, $warehouse_id, 'in', qty, avg_cost)` is called to increase the stock balance.

**Note:** The restock uses the item's current average cost, not the cost at the time of the original sale. This is a deliberate simplification.

#### GL Effect

The GL posting is handled by `SalesPostingService::postReturn`. It is **optional** — if `sales_journal_id` is not configured in `Settings`, the method returns `null` and the return posts without any GL entry. This allows the system to function before finance is configured.

When finance is configured, the journal entry reverses the original invoice's GL entries:

```
DR Revenue                  (quantity × unit_price per return line)
DR Tax Payable              (tax_amount per return line)
   CR Accounts Receivable   (return.total_amount)
```

For restocked stock items:
```
DR Inventory                (cogs_amount from the line)
   CR COGS                  (cogs_amount from the line)
```

The journal is immediately posted (not left in draft) via `JournalPostingService::post`, updating `account_period_balances`.

#### Invoice Payment Status Effect

Regardless of whether finance is configured, posting a return always updates the original invoice:

```php
$invoice->paid_amount += $return->total_amount
$invoice->payment_status = (paid_amount >= total_amount) ? 'paid'
                           : (paid_amount > 0)           ? 'partially_paid'
                                                         : 'unpaid'
```

This treats the return credit as a partial payment against the original invoice, reducing the customer's outstanding balance.

---

### 5.4 Payment Status Tracking

The `Invoice` model has two separate columns for payment tracking:

| Column | Meaning |
|--------|---------|
| `paid_amount` | Running sum of money actually applied to this invoice (from payments + return credits) |
| `payment_status` | Derived status: `unpaid`, `partially_paid`, or `paid` |

`paid_amount` and `payment_status` are updated in two code paths:

**Path 1 — SalesPayment posting** (`SalesPaymentService::applyToInvoice`):

```php
new_paid = invoice.paid_amount + applied_amount  // rounded to 2dp
payment_status = new_paid >= total_amount ? 'paid'
               : new_paid > 0             ? 'partially_paid'
                                          : 'unpaid'
```

**Path 2 — SalesReturn posting** (`SalesReturnService::post`):

Same formula, but `applied_amount` is the return's `total_amount`.

**Reversal path** (`SalesPaymentService::reverseFromInvoice`):

```php
new_paid = max(0, invoice.paid_amount - applied_amount)
// same three-way status determination
```

`payment_status` is never set directly by the user — it is always a computed outcome of the above operations. The `paid_amount` column accumulates money from both payment records (via the `sales_payment_invoices` pivot) and return credits.

The `Invoice.remainingBalance()` helper returns `max(0, total_amount - paid_amount)`.

---

### 5.5 Invoice GL Posting

Invoice posting is an explicit two-step process:
1. Confirm the invoice (`POST /sales/invoices/{invoice}/confirm`) — sets status to `confirmed`.
2. Post the invoice to GL (`POST /sales/invoices/{invoice}/post`) — calls `SalesPostingService::postInvoice`.

**Requirements for posting:**
- Invoice must be in `confirmed` status.
- `sales_journal_id` setting must be present in the `settings` table.
- A `FiscalPeriod` must exist for the invoice date.
- The branch must be set on the invoice.

**Journal entries created on posting:**

```
DR Accounts Receivable          invoice.total_amount
   CR Revenue account(s)        sum of (line_subtotal - discount_amount) per line
   CR Tax Payable account(s)    sum of line tax_amounts
```

For stock-type items only:
```
DR COGS account(s)              sum of cost of goods sold per line
   CR Inventory account(s)      same amounts
```

Account resolution uses a cascade (item-level → category-level → system default from `settings` table). The service throws a `RuntimeException` if an account cannot be resolved at any level.

The journal entry is created via `JournalEntryService::create` (status `draft`) and immediately posted via `JournalPostingService::post`, which updates `account_period_balances` for the trial balance. After posting, the invoice's `status`, `posted_at`, and `journal_entry_id` fields are updated.
