# Cycle 3: Financial Schema Gaps

## Goal
Add all missing financial tables, columns, services, and methods specified in `docs/02-financial-core.md`.

---

## New Migration: `2024_01_01_000046_create_refunds_table.php`

```sql
CREATE TABLE refunds (
    id              BIGSERIAL PRIMARY KEY,
    uuid            UUID NOT NULL UNIQUE,
    academy_id      BIGINT NOT NULL REFERENCES academies(id),
    payment_id      BIGINT NOT NULL REFERENCES payments(id),
    invoice_id      BIGINT NOT NULL REFERENCES invoices(id),
    amount          BIGINT NOT NULL,                           -- piasters, must be <= payment.amount - payment.refunded_amount
    reason          TEXT NOT NULL,
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',
    method          VARCHAR(20) NOT NULL,                      -- same method as original payment
    reference       VARCHAR(100),                              -- external ref (bank ref, gateway id)
    requested_by    BIGINT NOT NULL REFERENCES users(id),
    approved_by     BIGINT REFERENCES users(id),
    processed_by    BIGINT REFERENCES users(id),
    requested_at    TIMESTAMP NOT NULL DEFAULT NOW(),
    approved_at     TIMESTAMP,
    processed_at    TIMESTAMP,
    rejected_at     TIMESTAMP,
    rejection_reason TEXT,
    notes           TEXT,
    metadata        JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMP NOT NULL DEFAULT NOW()
    -- NO updated_at (immutable after processing)
);

-- CHECK constraint
ALTER TABLE refunds ADD CONSTRAINT refunds_status_check
    CHECK (status IN ('pending', 'approved', 'processed', 'rejected', 'cancelled'));

-- Indexes
CREATE INDEX refunds_academy_id_idx ON refunds(academy_id);
CREATE INDEX refunds_payment_id_idx ON refunds(payment_id);
CREATE INDEX refunds_status_idx ON refunds(status);
```

### Model: `app/Domain/Financial/Models/Refund.php`
- Traits: BelongsToAcademy, HasUuid (NO SoftDeletes — refunds are permanent)
- Casts: amount → integer, metadata → array, dates → datetime
- Relationships: payment(), invoice(), requestedBy(), approvedBy(), processedBy(), transactions()
- `const UPDATED_AT = null;`

### Service: `app/Domain/Financial/Services/RefundService.php`

```php
class RefundService
{
    const VALID_TRANSITIONS = [
        'pending' => ['approved', 'rejected', 'cancelled'],
        'approved' => ['processed', 'cancelled'],
    ];

    public function request(Payment $payment, int $amount, string $reason, User $actor): Refund
    {
        // Validation:
        // 1. amount > 0
        // 2. amount <= payment.amount - payment.refunded_amount
        // 3. payment.status must be 'confirmed'
        // 4. invoice cannot be 'cancelled' or 'draft'
        // If amount > system_setting('refund_auto_approve_limit', 50000):
        //   status = 'pending' (needs approval)
        // Else:
        //   status = 'approved' (auto-approve small refunds)
        // Dispatch RefundRequested event
    }

    public function approve(Refund $refund, User $actor): Refund
    {
        // assertTransition('pending', 'approved')
        // Dispatch RefundApproved event
    }

    public function reject(Refund $refund, string $reason, User $actor): Refund
    {
        // assertTransition('pending', 'rejected')
        // Dispatch RefundRejected event
    }

    public function process(Refund $refund, User $actor): Refund
    {
        // assertTransition('approved', 'processed')
        // Inside DB::transaction:
        //   1. Create reversing double-entry transactions (Debit: Revenue/Receivable, Credit: Cash/Card/Wallet)
        //   2. Update payment.refunded_amount += refund.amount
        //   3. If payment fully refunded: payment.status = 'refunded'
        //   4. Update invoice.paid_amount -= refund.amount
        //   5. Recalculate invoice.balance_due
        //   6. If invoice was 'paid' and now has balance: invoice.status = 'partially_paid'
        //   7. If wallet refund: WalletService::deposit() back to owner
        // Dispatch RefundProcessed event
    }
}
```

---

## New Migration: `2024_01_01_000047_create_credits_tables.php`

```sql
CREATE TABLE credits (
    id              BIGSERIAL PRIMARY KEY,
    uuid            UUID NOT NULL UNIQUE,
    academy_id      BIGINT NOT NULL REFERENCES academies(id),
    owner_type      VARCHAR(100) NOT NULL,
    owner_id        BIGINT NOT NULL,
    amount          BIGINT NOT NULL,                     -- piasters, remaining balance
    original_amount BIGINT NOT NULL,                     -- piasters, initial value
    reason          VARCHAR(200) NOT NULL,
    source          VARCHAR(30) NOT NULL,                -- CHECK below
    expires_at      TIMESTAMP,
    issued_by       BIGINT NOT NULL REFERENCES users(id),
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    metadata        JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMP NOT NULL DEFAULT NOW()
);

ALTER TABLE credits ADD CONSTRAINT credits_source_check
    CHECK (source IN ('refund', 'goodwill', 'promotion', 'correction', 'transfer'));

CREATE TABLE credit_applications (
    id              BIGSERIAL PRIMARY KEY,
    credit_id       BIGINT NOT NULL REFERENCES credits(id),
    invoice_id      BIGINT NOT NULL REFERENCES invoices(id),
    amount          BIGINT NOT NULL,                     -- piasters applied
    applied_by      BIGINT NOT NULL REFERENCES users(id),
    created_at      TIMESTAMP NOT NULL DEFAULT NOW()
    -- NO updated_at — immutable
);
```

### Models
- `Credit`: BelongsToAcademy, HasUuid; owner() MorphTo; applications() HasMany; issuer() BelongsTo
- `CreditApplication`: invoice() BelongsTo; credit() BelongsTo; appliedBy() BelongsTo

### Service: `CreditService`
- `issue(owner, amount, reason, source, expiresAt, actor)` — create credit
- `apply(credit, invoice, amount, actor)` — deduct from credit.amount, create application, update invoice
- `getAvailableCredits(owner)` — active, amount > 0, not expired
- `expire(credit)` — set is_active = false (called by scheduled job)

---

## New Migration: `2024_01_01_000048_create_payment_allocations_table.php`

```sql
CREATE TABLE payment_allocations (
    id              BIGSERIAL PRIMARY KEY,
    payment_id      BIGINT NOT NULL REFERENCES payments(id),
    invoice_id      BIGINT NOT NULL REFERENCES invoices(id),
    amount          BIGINT NOT NULL,              -- piasters allocated to this invoice
    created_at      TIMESTAMP NOT NULL DEFAULT NOW()
);

-- UNIQUE: one allocation per payment-invoice pair
CREATE UNIQUE INDEX payment_allocations_unique ON payment_allocations(payment_id, invoice_id);
```

**Purpose:** When a single payment covers multiple invoices (split allocation), this tracks how much went to each. PaymentService::recordPayment() creates one allocation per invoice.

---

## New Migration: `2024_01_01_000049_create_daily_closings_table.php`

```sql
CREATE TABLE daily_closings (
    id                  BIGSERIAL PRIMARY KEY,
    uuid                UUID NOT NULL UNIQUE,
    academy_id          BIGINT NOT NULL REFERENCES academies(id),
    branch_id           BIGINT REFERENCES branches(id),
    closing_date        DATE NOT NULL,
    total_invoiced      BIGINT NOT NULL DEFAULT 0,      -- sum of invoices created that day
    total_collected     BIGINT NOT NULL DEFAULT 0,      -- sum of confirmed payments
    total_refunded      BIGINT NOT NULL DEFAULT 0,
    total_cash          BIGINT NOT NULL DEFAULT 0,
    total_card          BIGINT NOT NULL DEFAULT 0,
    total_wallet        BIGINT NOT NULL DEFAULT 0,
    total_bank_transfer BIGINT NOT NULL DEFAULT 0,
    transactions_count  INTEGER NOT NULL DEFAULT 0,
    invoices_count      INTEGER NOT NULL DEFAULT 0,
    new_enrollments     INTEGER NOT NULL DEFAULT 0,
    cash_variance       BIGINT NOT NULL DEFAULT 0,      -- cash_session expected vs actual
    notes               TEXT,
    generated_at        TIMESTAMP NOT NULL DEFAULT NOW(),
    reviewed_by         BIGINT REFERENCES users(id),
    reviewed_at         TIMESTAMP,
    metadata            JSONB NOT NULL DEFAULT '{}',
    created_at          TIMESTAMP NOT NULL DEFAULT NOW()
);

ALTER TABLE daily_closings ADD CONSTRAINT daily_closings_unique
    UNIQUE (academy_id, branch_id, closing_date);
```

### Service: `DailyClosingService`
- `generate(Carbon $date, int $academyId, ?int $branchId)` — aggregate all financials
- `review(DailyClosing $closing, User $actor)` — mark as reviewed
- `getReport(Carbon $from, Carbon $to, ?int $branchId)` — return collection for date range

---

## New Migration: `2024_01_01_000050_add_missing_financial_columns.php`

### On `financial_accounts`:
```php
$table->string('normal_balance', 6)->default('debit'); // debit or credit
$table->bigInteger('opening_balance')->default(0);
$table->bigInteger('current_balance')->default(0);
```
CHECK: `normal_balance IN ('debit', 'credit')`

### On `invoices`:
```php
$table->foreignId('branch_id')->nullable()->constrained('branches');
$table->string('discount_type', 10)->nullable(); // fixed, percentage
$table->bigInteger('discount_value')->nullable();
$table->decimal('tax_rate', 5, 2)->nullable();
$table->timestamp('sent_at')->nullable();
$table->text('cancellation_reason')->nullable();
$table->text('internal_notes')->nullable();
$table->text('terms')->nullable();
$table->foreignId('cancelled_by')->nullable()->constrained('users');
```
CHECK: `discount_type IN ('fixed', 'percentage')`

### On `payments`:
```php
$table->foreignId('branch_id')->nullable()->constrained('branches');
$table->foreignId('cash_session_id')->nullable()->constrained('cash_sessions');
$table->text('internal_notes')->nullable();
$table->string('gateway_reference', 100)->nullable();
$table->foreignId('approved_by')->nullable()->constrained('users');
$table->string('failure_reason')->nullable();
$table->bigInteger('refunded_amount')->default(0);
```

### On `wallets`:
```php
$table->string('status', 20)->default('active'); // replace is_active boolean
$table->bigInteger('frozen_amount')->default(0);
$table->bigInteger('total_deposited')->default(0);
$table->bigInteger('total_spent')->default(0);
$table->timestamp('frozen_at')->nullable();
$table->string('frozen_reason')->nullable();
```
CHECK: `status IN ('active', 'frozen', 'closed')`
Remove `is_active` column (replace with status)

### On `payment_plans`:
```php
$table->bigInteger('total_amount')->default(0);
$table->bigInteger('down_payment')->default(0);
$table->string('late_fee_type', 10)->default('none'); // none, fixed, percentage
$table->bigInteger('late_fee_value')->default(0);
$table->integer('grace_period_days')->default(0);
$table->timestamp('paused_at')->nullable();
$table->text('pause_reason')->nullable();
$table->timestamp('defaulted_at')->nullable();
$table->integer('default_after_missed')->default(3);
$table->foreignId('created_by')->nullable()->constrained('users');
```
CHECK: `late_fee_type IN ('none', 'fixed', 'percentage')`
Add 'paused' to status CHECK: `('active', 'completed', 'defaulted', 'cancelled', 'paused')`

### On `installments`:
```php
$table->bigInteger('late_fee_amount')->default(0);
$table->bigInteger('total_due')->default(0); -- amount + late_fee
$table->bigInteger('paid_amount')->default(0);
$table->foreignId('waived_by')->nullable()->constrained('users');
$table->string('waived_reason')->nullable();
$table->timestamp('reminder_sent_at')->nullable();
```
Add 'partially_paid' to status CHECK

### On `cash_sessions`:
```php
$table->bigInteger('total_card')->default(0);
$table->timestamp('reconciled_at')->nullable();
$table->foreignId('reconciled_by')->nullable()->constrained('users');
$table->text('difference_reason')->nullable();
```
Add 'reconciled' to status CHECK: `('open', 'closed', 'suspended', 'reconciled')`

---

## Missing Service: `PaymentPlanService`

```php
class PaymentPlanService
{
    const VALID_TRANSITIONS = [
        'active' => ['paused', 'defaulted', 'cancelled', 'completed'],
        'paused' => ['active', 'cancelled'],
    ];

    public function create(Invoice $invoice, array $data, User $actor): PaymentPlan
    {
        // Validate: invoice status must be 'sent' or 'overdue'
        // Validate: total_installments >= 2
        // Calculate installment_amount = ceil((invoice.balance_due - down_payment) / total_installments)
        // Create plan + generate installment records with due dates
        // If down_payment > 0: record immediate payment via PaymentService
    }

    public function pause(PaymentPlan $plan, string $reason, User $actor): PaymentPlan
    {
        // Freeze all pending installments
        // Set paused_at
    }

    public function resume(PaymentPlan $plan, User $actor): PaymentPlan
    {
        // Recalculate due dates from today
        // Set paused_at = null
    }

    public function applyLateFee(Installment $installment): void
    {
        // Calculate fee based on plan's late_fee_type + late_fee_value
        // Update installment.late_fee_amount and total_due
        // Dispatch InstallmentOverdue event
    }

    public function checkAndDefault(PaymentPlan $plan): void
    {
        // Count consecutive overdue installments
        // If >= plan.default_after_missed: status → 'defaulted'
        // Dispatch PaymentPlanDefaulted event
    }

    public function recordInstallmentPayment(Installment $installment, User $actor): void
    {
        // Create payment via PaymentService for installment.total_due
        // Update installment: status → 'paid', paid_at, paid_amount
        // Update plan: paid_installments++
        // If all paid: plan.status → 'completed'
    }
}
```

---

## Missing Methods on Existing Services

### InvoiceService — Add:
```php
public function send(Invoice $invoice, User $actor): Invoice
{
    // Validate: status must be 'draft'
    // Transition: draft → sent
    // Set sent_at = now()
    // Dispatch InvoiceSent event
}

public function markOverdue(Invoice $invoice): Invoice
{
    // Validate: status in ('sent', 'partially_paid') AND due_date < today
    // Transition: → overdue
    // Dispatch InvoiceOverdue event
}

public function applyDiscount(Invoice $invoice, string $type, int $value, User $actor): Invoice
{
    // type = 'fixed' or 'percentage'
    // Calculate discount_amount
    // Recalculate total_amount and balance_due
    // Freeze if already sent (not allowed)
}

public function duplicate(Invoice $invoice, User $actor): Invoice
{
    // Clone invoice + items as new draft
    // New number, reset paid_amount/balance_due
}
```

### PaymentService — Add:
```php
public function allocate(Payment $payment, array $invoiceAmounts): void
{
    // $invoiceAmounts = [invoice_id => amount, ...]
    // Validate: sum(amounts) == payment.amount
    // Create PaymentAllocation per invoice
    // Update each invoice's paid_amount
}

public function confirm(Payment $payment, User $actor): Payment
{
    // For pending payments (e.g., bank_transfer awaiting verification)
    // Transition: pending → confirmed
    // Then process as if just received (update invoice, double-entry)
}
```

### WalletService — Add:
```php
public function pay(Wallet $wallet, int $amount, $reference, string $description, User $actor): WalletTransaction
{
    // Like withdraw but specifically for payments
    // Creates wallet_transaction with type = 'payment' and direction = 'debit'
    // Links to reference (invoice/pos_transaction)
}

public function freeze(Wallet $wallet, string $reason, User $actor): Wallet
{
    // status → 'frozen', frozen_at, frozen_reason
    // Dispatch WalletFrozen event
}

public function unfreeze(Wallet $wallet, User $actor): Wallet
{
    // status → 'active', clear frozen_at/reason
}

public function transfer(Wallet $from, Wallet $to, int $amount, User $actor): void
{
    // Validate: from.available_balance >= amount
    // Debit from, credit to (two wallet_transactions)
}
```

### CashSessionService — Add:
```php
public function reconcile(CashSession $session, User $actor): CashSession
{
    // Validate: status must be 'closed'
    // Set reconciled_at, reconciled_by
    // Status → 'reconciled'
}
```

---

## FinancialAccountsSeeder — Missing Accounts

Add these to the existing seeder:

| Code | Name | Name AR | Type | Normal Balance |
|------|------|---------|------|----------------|
| 1020 | Bank - Secondary | البنك - الثانوي | asset | debit |
| 1110 | Installments Receivable | ذمم الأقساط | asset | debit |
| 1300 | Inventory | المخزون | asset | debit |
| 1400 | Fixed Assets | الأصول الثابتة | asset | debit |
| 2030 | Security Deposits | تأمينات محصلة | liability | credit |
| 2040 | Tax Payable | ضرائب مستحقة | liability | credit |
| 3000 | Owner's Equity | رأس المال | equity | credit |
| 3100 | Retained Earnings | أرباح محتجزة | equity | credit |
| 4060 | Camp Revenue | إيرادات المعسكرات | revenue | credit |
| 4070 | Kit Sales Revenue | إيرادات بيع الحقائب | revenue | credit |
| 4080 | Late Fee Revenue | إيرادات غرامات التأخير | revenue | credit |
| 4090 | Other Revenue | إيرادات أخرى | revenue | credit |
| 5060 | Maintenance | صيانة | expense | debit |
| 5070 | Insurance | تأمين | expense | debit |
| 5080 | Professional Services | خدمات مهنية | expense | debit |
| 5090 | Refunds Issued | مرتجعات | expense | debit |
| 5100 | Bad Debt Write-offs | ديون معدومة | expense | debit |
| 5110 | Other Expenses | مصروفات أخرى | expense | debit |

---

## Verification Checklist

- [ ] `php artisan migrate` — all new tables created without error
- [ ] Refund flow: request → approve → process → verify double-entry + invoice update
- [ ] Credit: issue → apply to invoice → balance reduces correctly
- [ ] Payment plan: create with down payment → installments generated → late fee applied → default after 3 missed
- [ ] Wallet: deposit → freeze → unfreeze → pay → transfer
- [ ] Daily closing: generate for yesterday → aggregates match individual records
- [ ] All new models have Auditable trait
- [ ] All new services dispatch events
