---
description: Double-entry accounting, immutable transactions, invoice lifecycle.
globs: ["app/Domain/Financial/**", "database/migrations/**financial**", "database/migrations/**invoice**", "database/migrations/**payment**", "database/migrations/**wallet**", "database/migrations/**transaction**"]
---

# Financial Integrity

## Double-Entry — ONE row carrying BOTH sides

A `transactions` row holds `debit_account_id` **and** `credit_account_id`. There
is no pair of rows and there is no `account_id` column, and `type` says what
kind of movement it was — not which side it is.

This paragraph used to describe a two-row pair with a `type` of `'debit'` or
`'credit'`. That schema has never existed: `2024_01_01_000013` created the
single-row shape from the start. Anyone who wrote code from the old text got a
mass-assignment no-op and a row that silently failed to say anything.

```php
// The ONLY way to write a transaction. Amounts are always positive.
Transaction::create([
    'academy_id'        => $academyId,
    'branch_id'         => $branchId,     // or the ledger cannot be read per branch
    'debit_account_id'  => $asset->id,    // where the money landed
    'credit_account_id' => $revenue->id,  // what it was for
    'amount'            => $amount,
    'type'              => TransactionType::PaymentReceived,
    'transaction_date'  => $date,
]);
```

Accounts are resolved **by code, scoped to the academy**, through
`LedgerAccountResolver`, and a missing account is a hard failure. Ids differ per
tenant because every client seeded their own chart; hardcoding one is what
produced "Dr Cash / Cr Bank" on every payment this product ever took.

A payment covering several kinds of item produces one row per revenue account,
split in proportion to the invoice's own lines with `intdiv()` and the remainder
on the last row.

## Revenue Routing — WHICH account, and how much of it was tax

Never hardcode a revenue account and never write a second `match` over itemable
types. `RevenueRouter` is the only thing that decides where collected money
lands; `PaymentService` and `RefundService` both go through it, and anything new
that credits revenue must too.

```php
$allocation = $router->allocatePayment($academyId, $invoice, $amount);

foreach ($allocation->postings as $posting) {
    Transaction::create([
        'debit_account_id'  => $asset->id,
        'credit_account_id' => $posting->account->id,
        'amount'            => $posting->amount,
        'metadata'          => $posting->metadata(),   // which rule sent it here
        // …
    ]);
}
```

The rules live in `revenue_mappings` + `revenue_mapping_lines`, one per revenue
source, optionally narrowed to a single product / programme / event (specific
beats the academy default). Editing them is a screen — `financial.revenue-routing`
— not a deploy.

Non-negotiable:

- **Tax comes out first, and credits a LIABILITY.** VAT collected is owed to the
  Tax Authority, not earned. `RevenueRoutingService` refuses a tax account that
  is not `liability`. Egypt's standard rate is 14%; some services are 10% or
  exempt, which is why it is per-source and not one academy-wide switch.
- **The entry must balance before anything is written.** `RevenueAllocation::assertBalanced()`
  throws rather than posting credits that do not sum to the money that arrived.
  A wrong `transactions` row can only ever be reversed, never corrected.
- **Order is fixed:** flat amounts off the top, then percentages of the net,
  then one `remainder` line takes what is left. At most one remainder per rule
  (a partial unique index enforces it).
- **Integer piasters only,** floors everywhere, leftover to the closing line.
- **History does not move.** Rules apply to what is collected after they are
  saved; posted transactions keep the rule that produced them in `metadata`.
- An academy that has configured nothing behaves exactly as the system did
  before routing existed — the install migration seeds that behaviour as data.

## Transactions Are IMMUTABLE

- Never update a transaction row; corrections are NEW reversing entries.
- Never soft-delete one.
- Note that the table *does* carry `timestamps()`. Immutability here is a model
  convention (`const UPDATED_AT = null`), not a database constraint — so it is
  only as strong as the code that respects it.

## Invoice State Machine (ONLY valid transitions)

```
draft → sent → partially_paid → paid
draft → cancelled
sent → cancelled (ONLY if paid_amount == 0)
sent → overdue (auto, when due_date passes)
overdue → partially_paid → paid
```

INVALID: `partially_paid → cancelled`, `paid → cancelled`

## Invoice Calculation Order

```
subtotal = sum(item.line_total)
discount_amount = applied discount
total_amount = subtotal - discount_amount + tax_amount
balance_due = total_amount - paid_amount
```

Total is FROZEN at creation. Never recalculate from items after invoice is sent.

## Payment Rules

- Amount MUST be > 0 AND <= invoice.balance_due
- Cannot pay cancelled or draft invoice
- After payment: update invoice.paid_amount, recalculate balance_due
- If balance_due == 0: invoice status = 'paid'
- Double-entry: Debit Cash/Card/Wallet, Credit Accounts Receivable

## Wallet Rules

- Balance NEVER goes negative (check BEFORE deduct)
- Always `lockForUpdate()` before modifying balance
- Every change = wallet_transactions record
- available_balance = balance - frozen_amount

## Refund Rules

- Refund amount <= payment.amount - payment.refunded_amount
- Creates reversing double-entry transactions
- Updates invoice.paid_amount and balance_due
- Refunds above threshold require approval
