# RULE FILE 02 — Financial Module Rules

Zero-tolerance zone. Financial bugs destroy trust. Every rule here prevents a real scenario.

---

## Double-Entry Accounting — The Inviolable Law

**Every financial movement creates TWO entries**: one debit, one credit. The sum of all debits MUST equal the sum of all credits at all times, per academy.

### The `transactions` Table Contract

```
id, uuid, academy_id, account_id (FK financial_accounts),
type (debit/credit), amount (bigint, ALWAYS positive),
reference_type, reference_id (polymorphic — what caused this),
description, metadata (jsonb),
created_by, created_at
```

**IMMUTABLE**: Transactions have NO `updated_at`, NO `deleted_at`. Once written, NEVER modified. Corrections create NEW reversing entries.

### Transaction Creation — ALWAYS in Pairs

```php
// PaymentService::createTransaction — the ONLY way to write transactions
DB::transaction(function () {
    Transaction::create([
        'account_id' => $debitAccount->id,
        'type' => 'debit',
        'amount' => $amount, // positive
        'reference_type' => $referenceType,
        'reference_id' => $referenceId,
    ]);
    Transaction::create([
        'account_id' => $creditAccount->id,
        'type' => 'credit',
        'amount' => $amount, // positive
        'reference_type' => $referenceType,
        'reference_id' => $referenceId,
    ]);
});
```

### Chart of Accounts — Required Seed Per Academy

| Code | Name | Type | Normal Balance |
|------|------|------|----------------|
| 1000 | Cash on Hand | asset | debit |
| 1010 | Bank Account | asset | debit |
| 1020 | Card Payments Receivable | asset | debit |
| 1100 | Accounts Receivable | asset | debit |
| 1200 | Inventory | asset | debit |
| 2000 | Accounts Payable | liability | credit |
| 2010 | Advance Payments (Deposits) | liability | credit |
| 2020 | Wallet Balances | liability | credit |
| 3000 | Owner's Equity | equity | credit |
| 4000 | Program Revenue | revenue | credit |
| 4010 | Product Sales Revenue | revenue | credit |
| 4020 | Rental Revenue | revenue | credit |
| 4030 | Other Revenue | revenue | credit |
| 5000 | Salary Expense | expense | debit |
| 5010 | Rent Expense | expense | debit |
| 5020 | Equipment Expense | expense | debit |
| 5030 | Utilities Expense | expense | debit |
| 5040 | Discount Given | expense | debit |
| 5050 | Refund Expense | expense | debit |

This is seeded via `FinancialAccountsSeeder` when an academy is created. Academy-specific — each academy gets its own copy.

---

## Invoices

### The `invoices` Table Contract

```
id, uuid, academy_id, invoice_number,
billable_type, billable_id (polymorphic — who is billed),
issued_by (FK users), branch_id,
status, subtotal, discount_amount, tax_amount, total_amount,
paid_amount, balance_due,
due_date, paid_at, cancelled_at, cancelled_by, cancellation_reason,
notes, metadata (jsonb),
created_at, updated_at
```

### Invoice Status State Machine

```
draft → sent → partially_paid → paid
draft → cancelled
sent → cancelled (only if no payments)
sent → overdue (system auto-marks when due_date passes)
overdue → partially_paid → paid
partially_paid → cancelled (NEVER — has payments, can only refund)
paid → refunded (via refund flow, not status change)
```

**CHECK**: `status IN ('draft', 'sent', 'partially_paid', 'paid', 'overdue', 'cancelled', 'refunded')`

### Invoice Number Format

`INV-{YYYY}-{sequential_per_academy}` → `INV-2026-000142`

Generated by: `InvoiceService::generateNumber()`
Sequential counter: stored in `system_settings` table per academy per year.
**Must be gap-free within a year** — if invoice is cancelled, number is NOT reused.

### Invoice Calculation Rules — THE EXACT ORDER

```
1. Sum all item line_totals → subtotal
2. Apply invoice-level discount → discount_amount
3. subtotal - discount_amount = taxable_amount
4. taxable_amount × tax_rate = tax_amount
5. taxable_amount + tax_amount = total_amount
6. total_amount - sum(payments) = balance_due
7. IF balance_due == 0 → status = 'paid'
8. IF balance_due > 0 AND sum(payments) > 0 → status = 'partially_paid'
```

**NEVER recalculate total from items after creation** — the invoice total is FROZEN at creation time. Even if item prices change later, the invoice amount stays.

### Invoice Items — The Price Snapshot Principle

```
id, invoice_id, item_type (product/program/kit/service/fee/penalty),
item_id (nullable — polymorphic), 
description, description_ar,
quantity, unit_price, discount_amount, line_total,
metadata (jsonb), created_at
```

`unit_price` is the price AT THE TIME OF INVOICING. If the product price changes tomorrow, this invoice item still shows the old price. This is not a bug — it's financial record integrity.

`line_total = (quantity × unit_price) - discount_amount`

### Invoice Cancellation Rules

1. `draft` → can cancel freely (no payments exist)
2. `sent` → can cancel ONLY if `paid_amount == 0`
3. `partially_paid` or `paid` → CANNOT cancel. Must go through refund flow.
4. Cancellation sets: `status='cancelled'`, `cancelled_at=now()`, `cancelled_by=auth_user`
5. Cancellation creates reversing transactions (debit revenue, credit receivable)
6. Cancelled invoice numbers are NOT reused

---

## Payments

### The `payments` Table Contract

```
id, uuid, academy_id, invoice_id (FK invoices),
amount (bigint — ALWAYS positive for normal payments),
payment_method, reference_number (nullable — card auth, transfer ref),
received_by (FK users), branch_id,
status, refunded_amount (default 0),
paid_at, notes, metadata (jsonb),
created_at, updated_at
```

### Payment Method Values

```
CHECK (payment_method IN ('cash', 'card', 'bank_transfer', 'wallet', 'cheque', 'other'))
```

### Payment Status Values

```
CHECK (status IN ('confirmed', 'pending', 'failed', 'refunded', 'partially_refunded'))
```

### Payment Recording Rules

1. Payment amount CANNOT exceed invoice `balance_due`
2. After payment: update invoice `paid_amount += payment.amount`, recalculate `balance_due`
3. If `balance_due == 0` after payment → set invoice `status = 'paid'`, `paid_at = now()`
4. Each payment creates double-entry transactions:
   - Cash payment: Debit Cash (1000), Credit Accounts Receivable (1100)
   - Card payment: Debit Card Receivable (1020), Credit Accounts Receivable (1100)
   - Wallet payment: Debit Wallet Balances (2020), Credit Accounts Receivable (1100)
5. Split payments: multiple payment records on the same invoice — each is independent
6. Payment `received_by` = the user who physically handled the money (receptionist, not system)

### Payment Validation Before Recording

```php
// PaymentService::recordPayment() — pre-checks
assert($amount > 0, 'Payment amount must be positive');
assert($amount <= $invoice->balance_due, 'Payment exceeds balance due');
assert($invoice->status !== 'cancelled', 'Cannot pay cancelled invoice');
assert($invoice->status !== 'draft', 'Cannot pay draft invoice — send first');
```

---

## Refunds

### Refund Rules

1. Refund is a SEPARATE entity, not a negative payment
2. Refund references the original payment AND the invoice
3. Refund amount cannot exceed `payment.amount - payment.refunded_amount`
4. Refund creates reversing transactions (debit Receivable, credit Cash/Card)
5. After refund: update `payment.refunded_amount += refund.amount`
6. If fully refunded: `payment.status = 'refunded'`
7. If partially: `payment.status = 'partially_refunded'`
8. Invoice recalculates: `paid_amount -= refund.amount`, `balance_due += refund.amount`
9. **Refunds above threshold require approval** (threshold configurable per academy)
10. Refund to wallet: creates wallet deposit instead of cash out

---

## Wallets

### The `wallets` Table Contract

```
id, uuid, academy_id, owner_type, owner_id (polymorphic — Person or Guardian),
balance (bigint), frozen_amount (bigint default 0),
available_balance (computed: balance - frozen_amount),
status, last_transaction_at, created_at, updated_at
```

### Wallet Rules

1. `balance` NEVER goes negative — enforce in service with `lockForUpdate()`
2. Every balance change creates a `wallet_transactions` record (audit trail)
3. Wallet `status`: `'active', 'frozen', 'closed'`
4. Frozen wallet: can receive deposits, CANNOT withdraw
5. `available_balance = balance - frozen_amount` — frozen_amount is for pending refunds, holds
6. Wallet is per-PERSON (or per-guardian) — not per-user, not per-participant
7. When paying from wallet: check `available_balance >= amount` THEN `lockForUpdate()` THEN deduct

### Wallet Transaction Types

```
CHECK (type IN ('deposit', 'withdrawal', 'payment', 'refund', 'transfer_in', 'transfer_out', 'adjustment', 'expiry'))
```

### Wallet ↔ Payment Integration

When `payment_method = 'wallet'`:
1. Check wallet `available_balance >= amount`
2. Lock wallet row: `Wallet::where('id', $walletId)->lockForUpdate()->first()`
3. Deduct: `$wallet->balance -= $amount`
4. Create `wallet_transactions` record (type='payment')
5. Create `payments` record (method='wallet')
6. Create double-entry: Debit Wallet Balances (2020), Credit Accounts Receivable (1100)

---

## Payment Plans (Installments)

### The `payment_plans` Table Contract

```
id, uuid, academy_id, invoice_id (FK invoices),
total_amount, installment_count, installment_amount,
frequency (monthly/weekly/biweekly/custom),
start_date, status,
late_fee_amount (bigint), grace_period_days (integer),
created_by, created_at, updated_at
```

### Installments Table

```
id, payment_plan_id, installment_number,
amount, due_date, status,
paid_amount, paid_at, late_fee_charged (bigint),
payment_id (FK payments — nullable, linked when paid),
created_at, updated_at
```

### Payment Plan Rules

1. `total_amount` MUST equal `invoice.total_amount` (or remaining balance)
2. `installment_count × installment_amount` MAY exceed `total_amount` by up to 1 piaster (rounding) — last installment absorbs the difference
3. Installment `status`: `'pending', 'paid', 'overdue', 'defaulted', 'waived'`
4. Grace period: installment is NOT overdue until `due_date + grace_period_days`
5. Late fee: charged ONCE when status transitions to 'overdue' (not daily compounding)
6. Default detection: 3 consecutive overdue installments → plan status = 'defaulted', notify admin
7. Plan pause: admin can pause (freeze all future installment due dates by N days)
8. Plan `status`: `'active', 'completed', 'defaulted', 'cancelled', 'paused'`

---

## Cash Sessions

### Cash Session Rules

```
id, uuid, academy_id, branch_id, user_id,
opening_balance (bigint), closing_balance (bigint, nullable),
expected_closing (bigint, nullable — system-calculated),
variance (bigint, nullable — closing - expected),
status, opened_at, closed_at, notes, created_at, updated_at
```

1. Only ONE open cash session per user per branch at a time
2. `opening_balance` = physical cash counted at start
3. During session: system tracks all cash payments received and cash refunds given
4. `expected_closing = opening_balance + cash_in - cash_out`
5. At close: user enters `closing_balance` (physical count), system calculates `variance`
6. Variance > threshold → requires explanation (notes) + manager approval
7. Cash session MUST be closed before user's shift ends (enforced by reminder, not hard block)
8. Status: `'open', 'closed', 'reviewed'`

---

## Financial Audit Log Rules

1. Every financial action (payment, refund, cancellation, wallet change) creates an audit entry
2. Audit entries are IMMUTABLE — no update, no delete
3. Stores: who, what, when, old_value, new_value, ip_address
4. Retention: forever (never purge financial audit logs)
5. Queryable by: date range, user, action type, entity type
