---
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.

## 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
