# Phase 2 — Financial Core

## Goal

The Financial Module is the ONLY source of money in the system. No other module can create, receive, refund, cancel, discount, waive, or transfer money except through this module.

---

## Principle: The Single Financial Authority

Every module merely REQUESTS financial operations. The Financial Module decides if the request is valid, executes it, records it in the immutable ledger, and dispatches events for downstream consumers.

**No other module may:**
- Insert rows into financial tables directly
- Modify payment/invoice/transaction records
- Bypass the financial service layer
- Store amounts that aren't sourced from this module

---

## Module: Chart of Accounts (Double-Entry Bookkeeping)

### Entity: `financial_accounts`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| parent_id | FK financial_accounts | Nullable — tree structure |
| code | string(20) | UNIQUE per academy |
| name | string | |
| name_ar | string | |
| type | string(20) | CHECK: asset, liability, equity, revenue, expense |
| category | string(30) | Sub-classification within type |
| normal_balance | string(6) | CHECK: debit, credit — determined by type |
| is_system | boolean | Default false — system accounts can't be deleted |
| is_active | boolean | Default true |
| description | text | Nullable |
| opening_balance | bigint | Default 0 — piasters |
| current_balance | bigint | Default 0 — denormalized running balance |
| created_at | timestamp | |
| updated_at | timestamp | |

### Default Chart of Accounts (seeded per academy)

**Assets (normal balance: debit)**
- 1000 Cash on Hand
- 1010 Bank Account (Main)
- 1020 Bank Account (Secondary)
- 1100 Accounts Receivable
- 1110 Installments Receivable
- 1200 Prepaid Wallet Balances
- 1300 Inventory
- 1400 Fixed Assets

**Liabilities (normal balance: credit)**
- 2000 Accounts Payable
- 2010 Unearned Revenue (advance payments before service delivered)
- 2020 Refunds Payable
- 2030 Security Deposits Held
- 2040 Tax Payable

**Equity (normal balance: credit)**
- 3000 Owner's Equity
- 3100 Retained Earnings

**Revenue (normal balance: credit)**
- 4000 Training Program Revenue
- 4010 Registration Fee Revenue
- 4020 Equipment Sales Revenue
- 4030 Facility Rental Revenue
- 4040 Private Session Revenue
- 4050 Tournament Fee Revenue
- 4060 Camp Revenue
- 4070 Kit Sales Revenue
- 4080 Late Fee Revenue
- 4090 Other Revenue

**Expenses (normal balance: debit)**
- 5000 Trainer Compensation
- 5010 Staff Salaries
- 5020 Facility Rent/Lease
- 5030 Equipment Purchases
- 5040 Utilities
- 5050 Marketing
- 5060 Maintenance
- 5070 Insurance
- 5080 Professional Services
- 5090 Refunds Issued
- 5100 Bad Debt Write-offs
- 5110 Other Expenses

### Business Rules
- Academies can ADD accounts but cannot delete system accounts
- Every transaction must balance (total debits = total credits)
- Account codes must be unique within the academy
- Parent-child relationships form a tree (for drill-down reports)
- `current_balance` is denormalized — recalculated from transactions on demand or via scheduled job

---

## Module: Financial Transactions (Immutable Ledger)

### Entity: `transactions`

The atomic unit of the financial system. NEVER edited. NEVER deleted.

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| journal_entry_id | bigint | Groups related transactions (debit+credit pair) |
| debit_account_id | FK financial_accounts | |
| credit_account_id | FK financial_accounts | |
| amount | bigint | Always positive (direction determined by debit/credit) |
| currency | string(3) | Default 'EGP' |
| type | string(30) | CHECK: see below |
| description | text | Human-readable description |
| transaction_date | date | Business date (may differ from created_at) |
| reference_type | string | Nullable — polymorphic (Invoice, Payment, Refund, etc.) |
| reference_id | bigint | Nullable |
| payment_id | FK payments | Nullable |
| invoice_id | FK invoices | Nullable |
| metadata | jsonb | Default '{}' |
| reconciled | boolean | Default false — for bank reconciliation |
| reconciled_at | timestamp | Nullable |
| created_by | FK users | |
| created_at | timestamp | |

### Transaction Types
- `payment_received` — customer pays (debit: Cash/Bank, credit: Revenue/AR)
- `payment_made` — academy pays out (debit: Expense, credit: Cash/Bank)
- `invoice_issued` — obligation created (debit: AR, credit: Unearned Revenue)
- `revenue_recognized` — service delivered (debit: Unearned Revenue, credit: Revenue)
- `refund_issued` — money returned (debit: Refunds Expense, credit: Cash/Bank)
- `credit_applied` — credit memo applied to invoice (debit: Revenue, credit: AR)
- `discount_applied` — discount recorded (debit: Discount Expense, credit: AR)
- `transfer` — move between accounts (debit: destination, credit: source)
- `adjustment` — manual correction with reason (debit/credit varies)
- `fee_charged` — late fee or penalty (debit: AR, credit: Fee Revenue)
- `write_off` — uncollectable debt (debit: Bad Debt, credit: AR)
- `wallet_deposit` — customer loads wallet (debit: Cash, credit: Wallet Liability)
- `wallet_payment` — customer pays from wallet (debit: Wallet Liability, credit: Revenue)
- `opening_balance` — initial balance setup
- `salary_payment` — trainer/staff compensation (debit: Compensation Expense, credit: Cash/Bank)

### Immutability Rules
- Transactions can NEVER be updated or deleted
- Corrections create a REVERSAL transaction (same amount, opposite accounts) + new correct transaction
- This preserves complete audit history
- `reconciled` flag prevents accidental reversal of reconciled entries

---

## Module: Invoices

### Entity: `invoices`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| branch_id | FK branches | Nullable |
| number | string(30) | UNIQUE per academy, auto-generated |
| type | string(20) | CHECK: standard, recurring, credit_note, proforma, receipt |
| status | string(20) | CHECK: see lifecycle below |
| billable_type | string | Polymorphic — Participant, Guardian, Organization (external) |
| billable_id | bigint | |
| contact_name | string | Denormalized — what to print on invoice |
| contact_phone | string(20) | Denormalized |
| contact_email | string | Nullable |
| contact_address | text | Nullable |
| subtotal_amount | bigint | Sum of line items before discount/tax |
| discount_amount | bigint | Total discount |
| discount_type | string(10) | CHECK: fixed, percentage — for the whole-invoice discount |
| discount_value | bigint | The configured discount (not the calculated amount) |
| tax_amount | bigint | Total tax |
| tax_rate | decimal(5,2) | Nullable — percentage |
| total_amount | bigint | subtotal - discount + tax |
| paid_amount | bigint | Running total of confirmed payments |
| due_amount | bigint | total - paid |
| currency | string(3) | Default 'EGP' |
| issue_date | date | When invoice was issued |
| due_date | date | Nullable — when payment is expected |
| paid_at | timestamp | Nullable — when fully paid |
| sent_at | timestamp | Nullable — when sent to customer |
| cancelled_at | timestamp | Nullable |
| cancellation_reason | text | Nullable |
| notes | text | Nullable — appears on printed invoice |
| internal_notes | text | Nullable — internal only |
| terms | text | Nullable — payment terms text |
| metadata | jsonb | Default '{}' |
| template_id | FK templates | Nullable — which PDF template to use |
| created_by | FK users | |
| approved_by | FK users | Nullable |
| cancelled_by | FK users | Nullable |
| created_at | timestamp | |
| updated_at | timestamp | |
| deleted_at | timestamp | |

### Invoice Number Format
Configurable per academy. Default: `INV-{YEAR}{MONTH}-{SEQUENCE}`
Example: `INV-202601-000001`
Credit notes: `CN-{YEAR}{MONTH}-{SEQUENCE}`
Receipts: `RCP-{YEAR}{MONTH}-{SEQUENCE}`

### Invoice Status Lifecycle

```
draft → pending → sent → partially_paid → paid
                              ↓
                         overdue
                              ↓
draft → pending → sent → overdue → written_off
   ↓                        ↓
cancelled              disputed

Any state → cancelled (with reason)
paid → partially_refunded → refunded
```

**Valid transitions:**
| From | To | Trigger |
|------|----|---------|
| draft | pending | Finalize (locks line items) |
| draft | cancelled | User cancels before sending |
| pending | sent | Send to customer (email/print) |
| pending | paid | Payment received in full |
| pending | partially_paid | Partial payment received |
| sent | paid | Payment received in full |
| sent | partially_paid | Partial payment received |
| sent | overdue | Due date passed (automated job) |
| partially_paid | paid | Remaining balance paid |
| partially_paid | overdue | Due date passed |
| overdue | paid | Late payment received |
| overdue | partially_paid | Late partial payment |
| overdue | written_off | Manager writes off bad debt |
| overdue | disputed | Customer disputes |
| paid | partially_refunded | Partial refund issued |
| partially_refunded | refunded | Full refund issued |
| any | cancelled | With reason + approval if required |

### Entity: `invoice_items`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| invoice_id | FK invoices | CASCADE |
| itemable_type | string | Nullable polymorphic — TrainingProgram, Product, Kit, Service |
| itemable_id | bigint | Nullable |
| description | string | Required — what to print |
| description_ar | string | Nullable |
| quantity | integer | Default 1 |
| unit_price | bigint | Price per unit at time of invoice (piasters) |
| discount_amount | bigint | Default 0 — line-item discount |
| tax_amount | bigint | Default 0 — line-item tax |
| total_amount | bigint | (quantity × unit_price) - discount + tax |
| period_start | date | Nullable — subscription start |
| period_end | date | Nullable — subscription end |
| metadata | jsonb | Default '{}' |
| sort_order | integer | Default 0 |
| created_at | timestamp | |
| updated_at | timestamp | |

### Important: Price Snapshot
`unit_price` is the price AT THE TIME of invoice creation. Even if the program price changes tomorrow, this invoice keeps its original price. This is the "historical records" cross-cutting concern applied to money.

---

## Module: Payments

### Entity: `payments`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| branch_id | FK branches | Nullable |
| invoice_id | FK invoices | Nullable — can be standalone payment |
| cash_session_id | FK cash_sessions | Nullable — if paid at reception |
| reference | string(50) | Auto-generated receipt number |
| direction | string(10) | CHECK: inbound, outbound |
| method | string(20) | CHECK: cash, card, bank_transfer, wallet, online, cheque, mobile_money, other |
| status | string(20) | CHECK: pending, confirmed, failed, cancelled, refunded, partially_refunded |
| payer_type | string | Polymorphic — who paid |
| payer_id | bigint | |
| amount | bigint | Piasters |
| currency | string(3) | Default 'EGP' |
| payment_date | date | Business date of payment |
| confirmed_at | timestamp | Nullable |
| failed_at | timestamp | Nullable |
| failure_reason | string | Nullable |
| notes | text | Nullable |
| internal_notes | text | Nullable |
| gateway_reference | string | Nullable — external payment gateway ref |
| gateway_data | jsonb | Default '{}' — full gateway response |
| metadata | jsonb | Default '{}' |
| received_by | FK users | Nullable — who received cash/card |
| created_by | FK users | |
| approved_by | FK users | Nullable — for amounts requiring approval |
| created_at | timestamp | |
| updated_at | timestamp | |
| deleted_at | timestamp | |

### Payment Allocation (for partial payments / splitting)

When one payment covers multiple invoices, or one invoice receives multiple payments:

### Entity: `payment_allocations`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| payment_id | FK payments | |
| invoice_id | FK invoices | |
| amount | bigint | How much of this payment applies to this invoice |
| created_at | timestamp | |

This enables:
- One payment split across 3 invoices
- One invoice paid by 5 separate payments
- Overpayment detection (allocated > invoice total)
- Under-payment tracking (sum(allocations) < payment amount → remainder goes to wallet or unallocated)

---

## Module: Refunds

### Entity: `refunds`

First-class entity — not just a negative payment.

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| invoice_id | FK invoices | Which invoice is being refunded |
| payment_id | FK payments | Which payment sourced the refund (nullable if credit-based) |
| refund_number | string(30) | Auto-generated: REF-YYYYMM-NNNNNN |
| amount | bigint | Piasters |
| reason | string(30) | CHECK: customer_request, service_not_delivered, duplicate_payment, overcharge, policy, other |
| reason_notes | text | Nullable — detailed explanation |
| method | string(20) | CHECK: same_as_original, cash, bank_transfer, wallet_credit, cheque |
| status | string(20) | CHECK: pending_approval, approved, processing, completed, rejected |
| requested_by | FK users | |
| approved_by | FK users | Nullable |
| approved_at | timestamp | Nullable |
| rejected_by | FK users | Nullable |
| rejected_at | timestamp | Nullable |
| rejection_reason | text | Nullable |
| completed_at | timestamp | Nullable |
| created_at | timestamp | |
| updated_at | timestamp | |

### Refund Approval Workflow
1. Anyone with `financial.refunds.create` permission can REQUEST a refund
2. If amount > academy's `refund_approval_threshold` → status = `pending_approval`
3. If amount <= threshold → auto-approved, status = `approved`
4. User with `financial.refunds.approve` reviews and approves/rejects
5. On approval: refund processed, invoice status updated, transaction created
6. On rejection: reason recorded, requestor notified

### Edge Cases
- Refund of a wallet payment → credit goes back to wallet (not cash)
- Partial refund: only part of an invoice refunded, due_amount adjusted
- Refund of an installment payment: which installment gets reversed?
- Refund after the cash session was closed: appears in next day's session
- Refund of a card payment: gateway reversal vs manual refund

---

## Module: Credits & Discounts

### Entity: `credits`

A credit is money owed TO the customer that hasn't been applied yet.

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| customer_type | string | Polymorphic |
| customer_id | bigint | |
| amount | bigint | Total credit amount (piasters) |
| remaining_amount | bigint | Unapplied balance |
| reason | string | Required — why credit exists |
| source_type | string | CHECK: refund, overpayment, promotional, goodwill, system_error |
| source_id | bigint | Nullable — link to source entity |
| expires_at | timestamp | Nullable — credits can expire |
| status | string(20) | CHECK: active, partially_used, fully_used, expired, cancelled |
| created_by | FK users | |
| created_at | timestamp | |
| updated_at | timestamp | |

### Entity: `credit_applications`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| credit_id | FK credits | |
| invoice_id | FK invoices | Applied to which invoice |
| amount | bigint | How much applied |
| applied_by | FK users | |
| applied_at | timestamp | |

---

## Module: Wallets (Prepaid Balances)

### Entity: `wallets`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| owner_type | string | Polymorphic — Participant, Guardian, Employee |
| owner_id | bigint | |
| balance | bigint | Current balance in piasters. Can be 0, never negative. |
| total_deposited | bigint | Lifetime deposits (for reporting) |
| total_spent | bigint | Lifetime spending (for reporting) |
| currency | string(3) | Default 'EGP' |
| is_active | boolean | Default true |
| frozen_at | timestamp | Nullable — frozen wallets can't be used |
| frozen_reason | string | Nullable |
| created_at | timestamp | |
| updated_at | timestamp | |

### Entity: `wallet_transactions`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| wallet_id | FK wallets | |
| type | string(20) | CHECK: deposit, payment, refund, adjustment, transfer_in, transfer_out, expiry |
| direction | string(6) | CHECK: credit, debit |
| amount | bigint | Always positive |
| balance_before | bigint | Snapshot |
| balance_after | bigint | Snapshot |
| reference_type | string | Nullable polymorphic — Invoice, Payment, Refund |
| reference_id | bigint | Nullable |
| description | string | Required |
| description_ar | string | Nullable |
| metadata | jsonb | Default '{}' |
| created_by | FK users | |
| created_at | timestamp | |

### Wallet Business Rules
- Balance can NEVER go negative (check before withdrawal)
- Wallet deposit creates: Payment (inbound) + Transaction (debit Cash, credit Wallet Liability)
- Wallet spending creates: WalletTransaction (debit) + Transaction (debit Wallet Liability, credit Revenue)
- Frozen wallets: can receive deposits but cannot spend
- Wallet transfer between family members: guardian → child, child A → child B
- Wallet refund: money returns to wallet, not cash (unless explicitly requested)
- Wallet expiry: configurable per academy (some credits expire after 12 months)

---

## Module: Payment Plans (Installments)

### Entity: `payment_plans`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| invoice_id | FK invoices | |
| customer_type | string | Polymorphic |
| customer_id | bigint | |
| total_amount | bigint | Plan total (may include fees) |
| down_payment | bigint | Default 0 — upfront payment |
| installment_amount | bigint | Per-installment amount |
| total_installments | integer | |
| paid_installments | integer | Default 0 |
| frequency | string(20) | CHECK: weekly, biweekly, monthly, quarterly, custom |
| start_date | date | First installment due date |
| next_due_date | date | Nullable |
| late_fee_type | string(10) | CHECK: none, fixed, percentage |
| late_fee_value | bigint | Nullable — amount or percentage basis |
| grace_period_days | integer | Default 0 — days after due before late fee |
| status | string(20) | CHECK: active, completed, defaulted, cancelled, paused |
| paused_at | timestamp | Nullable |
| pause_reason | text | Nullable |
| defaulted_at | timestamp | Nullable |
| default_after_missed | integer | Default 3 — how many missed before auto-default |
| notes | text | Nullable |
| created_by | FK users | |
| created_at | timestamp | |
| updated_at | timestamp | |

### Entity: `installments`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| payment_plan_id | FK payment_plans | |
| sequence | integer | 1, 2, 3... |
| amount | bigint | Expected amount |
| late_fee_amount | bigint | Default 0 — applied if late |
| total_due | bigint | amount + late_fee_amount |
| paid_amount | bigint | Default 0 |
| due_date | date | |
| paid_at | timestamp | Nullable |
| payment_id | FK payments | Nullable — link to actual payment |
| status | string(20) | CHECK: upcoming, due, overdue, paid, partially_paid, waived, cancelled |
| waived_by | FK users | Nullable |
| waived_reason | string | Nullable |
| reminder_sent_at | timestamp | Nullable — when reminder notification sent |
| created_at | timestamp | |
| updated_at | timestamp | |

### Installment Business Rules
- Auto-generate installments when plan is created (based on frequency + start_date)
- Installment #1 can be larger (down payment handling)
- Late fee auto-applied by scheduled job (respects grace period)
- Reminder notification: configurable days before due (3 days, 1 day, on due date)
- Missed installments: after `default_after_missed` consecutive misses → plan status = defaulted
- Defaulted plan: triggers notification, can auto-suspend participant enrollment
- Waive: specific installments can be waived (forgiven) by authorized users
- Pause: freezes upcoming installments (due dates shift forward when resumed)
- Partial payment on installment: status = partially_paid, remainder rolls to next? Or carries?
  → Configurable per academy: "partial carries forward" vs "partial closes current, remainder is new"
- Plan cancellation: remaining installments cancelled, paid ones stay

---

## Module: Cash Sessions (Daily Operations)

### Entity: `cash_sessions`

A cash session represents one operator's cash drawer during a shift.

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| branch_id | FK branches | |
| user_id | FK users | Who owns this session |
| opening_balance | bigint | Cash in drawer at start |
| expected_closing_balance | bigint | Calculated: opening + inbound_cash - outbound_cash |
| actual_closing_balance | bigint | Nullable — entered by operator at close |
| difference | bigint | Nullable — actual - expected (positive = over, negative = short) |
| total_inbound | bigint | Sum of all inbound cash payments |
| total_outbound | bigint | Sum of all outbound cash payments |
| total_card | bigint | Sum of card payments (not in drawer) |
| total_transactions | integer | Count of transactions |
| status | string(20) | CHECK: open, closed, reconciled, disputed |
| opened_at | timestamp | |
| closed_at | timestamp | Nullable |
| reconciled_at | timestamp | Nullable |
| reconciled_by | FK users | Nullable |
| notes | text | Nullable |
| difference_reason | text | Nullable — required if difference ≠ 0 |
| created_at | timestamp | |
| updated_at | timestamp | |

### Cash Session Business Rules
- Only ONE open session per user per branch at a time
- All cash/card payments during the session are linked to it
- Closing requires entering actual cash count
- If difference > threshold → requires manager reconciliation
- End-of-day report: all closed sessions + unmatched differences
- Cannot delete or edit a closed session — only reconcile

---

## Module: Daily Closing

### Entity: `daily_closings`

End-of-day financial summary per branch.

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| academy_id | FK organizations | |
| branch_id | FK branches | |
| closing_date | date | UNIQUE per branch |
| total_revenue | bigint | All confirmed inbound payments |
| total_expenses | bigint | All confirmed outbound payments |
| total_cash | bigint | Cash portion |
| total_card | bigint | Card portion |
| total_bank_transfer | bigint | Bank transfer portion |
| total_wallet | bigint | Wallet payments portion |
| invoices_created | integer | Count |
| invoices_paid | integer | Count |
| invoices_overdue | integer | Count |
| new_participants | integer | Count |
| sessions_conducted | integer | Count |
| attendance_rate | decimal(5,2) | Percentage |
| status | string(20) | CHECK: open, closed, approved |
| closed_by | FK users | Nullable |
| closed_at | timestamp | Nullable |
| approved_by | FK users | Nullable |
| notes | text | Nullable |
| created_at | timestamp | |
| updated_at | timestamp | |

---

## Module: Financial Audit

### Entity: `financial_audit_log`

Separate from general audit — more detailed for financial compliance.

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| academy_id | FK organizations | |
| action | string(30) | invoice_created, payment_received, refund_issued, write_off, adjustment, etc. |
| entity_type | string | Invoice, Payment, Transaction, Refund, Wallet |
| entity_id | bigint | |
| amount | bigint | The financial amount involved |
| currency | string(3) | |
| description | text | Human-readable description of what happened |
| before_state | jsonb | Nullable — entity state before change |
| after_state | jsonb | Nullable — entity state after change |
| user_id | FK users | Who did it |
| ip_address | string(45) | |
| user_agent | text | |
| created_at | timestamp | NEVER editable |

### What Gets Logged
- Every invoice state change (with before/after status)
- Every payment (with method, amount, allocation)
- Every refund (with approval chain)
- Every write-off (with reason)
- Every manual adjustment (with justification)
- Every wallet operation
- Every plan default/pause/resume
- Cash session open/close with differences
- Daily closing approval

---

## Services Layer

### `InvoiceService`
- `create(data, items, creator)` — creates draft invoice with items
- `finalize(invoice)` — locks items, transitions to pending
- `send(invoice, channel)` — sends via email/SMS, transitions to sent
- `cancel(invoice, reason, user)` — cancels with reason + permission check
- `addItem(invoice, item)` — only if draft
- `removeItem(invoice, itemId)` — only if draft
- `applyDiscount(invoice, discountType, value)` — recalculates
- `recalculateTotals(invoice)` — sum items → subtotal → apply discount → apply tax → total
- `generateNumber(academyId, type)` — next number in sequence
- `duplicate(invoice)` — clone as new draft (for recurring)
- `markOverdue()` — scheduled: find sent/partially_paid past due_date → mark overdue

### `PaymentService`
- `record(data, creator)` — records payment, allocates to invoice(s), creates transaction
- `allocate(payment, invoiceAllocations)` — split payment across invoices
- `confirm(payment, user)` — for pending payments needing confirmation
- `fail(payment, reason)` — mark as failed
- `cancel(payment, reason, user)` — cancel unconfirmed payment

### `RefundService`
- `request(invoice, amount, reason, method, user)` — creates refund request
- `approve(refund, user)` — approves and processes
- `reject(refund, reason, user)` — rejects with reason
- `process(refund)` — executes the actual refund (wallet credit or outbound payment)

### `WalletService`
- `getOrCreate(owner, academyId)` — find or create wallet
- `deposit(wallet, amount, description, reference, user)` — add funds
- `withdraw(wallet, amount, description, reference, user)` — remove funds (fails if insufficient)
- `pay(wallet, invoice, amount, user)` — pay invoice from wallet balance
- `transfer(sourceWallet, destWallet, amount, user)` — family transfer
- `freeze(wallet, reason, user)` — block spending
- `unfreeze(wallet, user)` — resume spending

### `PaymentPlanService`
- `create(invoice, options, user)` — creates plan + generates installments
- `recordInstallmentPayment(installment, payment)` — marks installment paid
- `applyLateFees()` — scheduled: find overdue installments past grace → add fee
- `checkDefaults()` — scheduled: find plans with N missed → mark defaulted
- `pause(plan, reason, user)` — freeze plan
- `resume(plan, user)` — shift remaining dates forward
- `waiveInstallment(installment, reason, user)` — forgive one installment

### `CashSessionService`
- `open(branch, user, openingBalance)` — start new session
- `close(session, actualBalance, notes)` — end session, calculate difference
- `reconcile(session, reconciler, notes)` — manager approves/explains difference

### `DailyClosingService`
- `generateClosing(branch, date)` — compile day's totals
- `close(closing, user)` — mark as closed
- `approve(closing, user)` — manager sign-off

---

## Scheduled Jobs (Financial)

| Job | Frequency | Action |
|-----|-----------|--------|
| `MarkOverdueInvoices` | Daily | Find sent/partially_paid past due_date → status=overdue |
| `ApplyInstallmentLateFees` | Daily | Find overdue installments past grace → add late_fee_amount |
| `CheckPaymentPlanDefaults` | Daily | Find plans with N missed → status=defaulted → notify |
| `SendInstallmentReminders` | Daily | Find upcoming installments within reminder window → send notification |
| `ExpireWalletCredits` | Daily | Find expired credits → status=expired |
| `GenerateDailyClosing` | Daily (midnight) | Compile financial summary per branch |
| `ReconcileAccountBalances` | Weekly | Recalculate current_balance from transactions (integrity check) |

---

## Events Dispatched

| Event | Trigger | Consumers |
|-------|---------|-----------|
| `InvoiceCreated` | New invoice finalized | Notification (if configured) |
| `InvoiceSent` | Invoice emailed/SMSed | Audit log |
| `InvoiceOverdue` | Scheduled job marks overdue | Notification to customer, internal alert |
| `InvoicePaid` | Full payment received | Notification, enrollment activation (if training), receipt generation |
| `PaymentReceived` | Any confirmed payment | Audit, daily summary update |
| `RefundRequested` | Refund request created | Notification to approver |
| `RefundApproved` | Refund approved | Process refund, notify customer |
| `RefundRejected` | Refund rejected | Notify requestor |
| `InstallmentOverdue` | Installment past due | Notification to customer |
| `PaymentPlanDefaulted` | Plan marked defaulted | Notification, suspension trigger |
| `WalletDeposited` | Wallet topped up | Notification (confirmation) |
| `CashSessionClosed` | Operator closes session | Manager notification if difference |
| `DailyClosingReady` | Auto-generated at midnight | Manager notification for approval |

---

## Edge Cases Catalog

1. **Overpayment:** Customer pays more than invoice total → excess goes to wallet (configurable) or creates credit
2. **Double payment:** Same invoice paid twice → second payment flagged, auto-refund or wallet credit
3. **Payment for wrong invoice:** Reallocation — move allocation from invoice A to invoice B
4. **Cancelled invoice with payments:** All payments must be refunded before invoice can be cancelled
5. **Invoice for inactive participant:** Allowed — debt still exists even if participant left
6. **Zero-amount invoice:** Valid for record-keeping (scholarship, comp session)
7. **Future-dated payment:** Payment date in future — status=pending until date arrives
8. **Multi-currency:** Not in v1. All amounts in academy's currency. Future: exchange rates table
9. **VAT/Tax changes:** Tax rate on invoice is snapshotted at creation. Rate changes don't affect existing invoices.
10. **Decimal amounts after discount:** 33.33% discount on 100 = 33.33 → stored as 3333 piasters. Rounding: always round to customer's benefit (floor on charges, ceil on refunds).
11. **Installment partial payment:** If someone pays 80 of 100 installment → installment is "partially_paid", 20 carries based on academy policy
12. **Wallet balance during plan default:** Customer has wallet balance but plan defaulted → wallet is NOT auto-used (manual decision)
13. **Deleted customer with outstanding balance:** Soft-delete person, invoice/debt remains, wallet frozen
14. **Transfer between payment methods:** Customer paid cash, wants receipt to show card → NOT allowed. Create refund + new payment.
