# Task 19 — Sales → Finance Posting

## Goal
Create the SalesPostingService that generates correct journal entries for:
1. Sales Invoice posting
2. Payment posting
3. Return/Credit Note posting

This is the most critical task. Double-entry bookkeeping must always balance.

---

## Prerequisites

Before this task:
- Finance module operational (journals, accounts, journal_entries, journal_entry_lines)
- Task 15 (Invoice Expansion) complete
- Task 16 (Payments) complete
- Task 20 (Inventory Posting) should be done alongside this task

---

## Service: `app/Services/Sales/Finance/SalesPostingService.php`

```php
namespace App\Services\Sales\Finance;

use App\Models\Sales\Invoice;
use App\Models\Sales\Payment\SalesPayment;
use App\Models\Sales\Return\SalesReturn;
use App\Services\Finance\JournalEntryService;
use App\Models\Core\Setting;
use Illuminate\Support\Facades\DB;

class SalesPostingService
{
    public function __construct(
        private JournalEntryService $journalEntryService
    ) {}

    // =====================================================
    // INVOICE POSTING
    // =====================================================

    public function postInvoice(Invoice $invoice): void
    {
        DB::transaction(function () use ($invoice) {
            $lines = $this->buildInvoiceJournalLines($invoice);
            $this->validateBalanced($lines, "Invoice #{$invoice->invoice_number}");

            $journalEntry = $this->journalEntryService->create([
                'journal_id'    => $this->getSalesJournalId(),
                'entry_date'    => $invoice->invoice_date,
                'branch_id'     => $invoice->branch_id,
                'fiscal_year_id'=> $this->getFiscalYearId($invoice->invoice_date),
                'fiscal_period_id' => $this->getFiscalPeriodId($invoice->invoice_date),
                'reference'     => $invoice->invoice_number,
                'description'   => "Sales Invoice: {$invoice->invoice_number}",
                'lines'         => $lines,
            ]);

            $invoice->update([
                'journal_entry_id' => $journalEntry->id,
                'status'           => 'posted',
                'posted_at'        => now(),
            ]);

            // If invoice has stock items, post COGS entries
            $this->postCOGS($invoice, $journalEntry);
        });
    }

    private function buildInvoiceJournalLines(Invoice $invoice): array
    {
        $lines = [];

        // Group revenue by account (multiple lines may share same revenue account)
        $revenueByAccount  = [];
        $taxByAccount      = [];
        $totalDebit        = 0;

        foreach ($invoice->lines as $invoiceLine) {
            // Revenue: credit revenue account
            $revenueAccountId = $this->resolveRevenueAccount($invoiceLine);
            $lineRevenue      = (float) $invoiceLine->line_subtotal - (float) $invoiceLine->discount_amount;
            $revenueByAccount[$revenueAccountId] = ($revenueByAccount[$revenueAccountId] ?? 0) + $lineRevenue;

            // Tax: credit tax payable
            if ($invoiceLine->tax_amount > 0 && $invoiceLine->tax_rate_id) {
                $taxAccountId = $this->resolveTaxPayableAccount($invoiceLine->taxRate);
                $taxByAccount[$taxAccountId] = ($taxByAccount[$taxAccountId] ?? 0) + (float) $invoiceLine->tax_amount;
            }

            $totalDebit += $lineRevenue + (float) $invoiceLine->tax_amount;
        }

        // Header discount: debit discount account
        if ($invoice->discount_amount > 0) {
            $totalDebit -= (float) $invoice->discount_amount;
            $lines[] = [
                'account_id' => $this->getSalesDiscountAccountId(),
                'debit'      => (float) $invoice->discount_amount,
                'credit'     => 0,
                'description'=> "Sales discount: {$invoice->invoice_number}",
            ];
        }

        // Debit: Accounts Receivable
        $lines[] = [
            'account_id' => $this->resolveARAccount($invoice->customer),
            'debit'      => round($totalDebit, 2),
            'credit'     => 0,
            'description'=> "AR: {$invoice->customer->code} — {$invoice->invoice_number}",
        ];

        // Credit: Revenue accounts
        foreach ($revenueByAccount as $accountId => $amount) {
            $lines[] = [
                'account_id' => $accountId,
                'debit'      => 0,
                'credit'     => round($amount, 2),
                'description'=> "Revenue: {$invoice->invoice_number}",
            ];
        }

        // Credit: Tax payable accounts
        foreach ($taxByAccount as $accountId => $amount) {
            $lines[] = [
                'account_id' => $accountId,
                'debit'      => 0,
                'credit'     => round($amount, 2),
                'description'=> "Tax: {$invoice->invoice_number}",
            ];
        }

        return $lines;
    }

    // =====================================================
    // COGS POSTING (stock items only)
    // =====================================================

    private function postCOGS(Invoice $invoice, $journalEntry): void
    {
        $cogsByAccount       = [];
        $inventoryByAccount  = [];

        foreach ($invoice->lines as $line) {
            if ($line->item->type !== 'stock') continue;

            // Get COGS amount from stock movement (set in Task 09)
            $movement = StockMovement::where('reference_type', 'invoice')
                ->where('reference_id', $invoice->id)
                ->where('item_id', $line->item_id)
                ->first();

            if (!$movement) continue;

            $cogsAmount      = (float) $movement->total_cost;
            $cogsAccountId   = $this->resolveCOGSAccount($line->item);
            $invAccountId    = $this->resolveInventoryAccount($line->item);

            $cogsByAccount[$cogsAccountId]      = ($cogsByAccount[$cogsAccountId] ?? 0) + $cogsAmount;
            $inventoryByAccount[$invAccountId]  = ($inventoryByAccount[$invAccountId] ?? 0) + $cogsAmount;

            $line->update(['cogs_amount' => $cogsAmount]);
        }

        if (empty($cogsByAccount)) return;

        // Add COGS lines to the same journal entry
        foreach ($cogsByAccount as $accountId => $amount) {
            $this->journalEntryService->addLine($journalEntry, [
                'account_id' => $accountId,
                'debit'      => round($amount, 2),
                'credit'     => 0,
                'description'=> "COGS: {$invoice->invoice_number}",
            ]);
        }
        foreach ($inventoryByAccount as $accountId => $amount) {
            $this->journalEntryService->addLine($journalEntry, [
                'account_id' => $accountId,
                'debit'      => 0,
                'credit'     => round($amount, 2),
                'description'=> "Inventory out: {$invoice->invoice_number}",
            ]);
        }
    }

    // =====================================================
    // PAYMENT POSTING
    // =====================================================

    public function postPayment(SalesPayment $payment): void
    {
        DB::transaction(function () use ($payment) {
            $cashAccountId = $payment->paymentMethod->account_id;
            $arAccountId   = $this->resolveARAccount($payment->invoice->customer);

            $lines = [
                // Debit: Cash/Bank
                ['account_id' => $cashAccountId, 'debit' => (float)$payment->amount, 'credit' => 0,
                 'description' => "Payment received: {$payment->payment_number}"],
                // Credit: AR
                ['account_id' => $arAccountId, 'debit' => 0, 'credit' => (float)$payment->amount,
                 'description' => "AR settled: {$payment->invoice->invoice_number}"],
            ];

            $this->validateBalanced($lines, "Payment #{$payment->payment_number}");

            $journalEntry = $this->journalEntryService->create([
                'journal_id'       => $this->getSalesJournalId(),
                'entry_date'       => $payment->payment_date,
                'branch_id'        => $payment->branch_id,
                'fiscal_year_id'   => $this->getFiscalYearId($payment->payment_date),
                'fiscal_period_id' => $this->getFiscalPeriodId($payment->payment_date),
                'reference'        => $payment->payment_number,
                'description'      => "Customer payment: {$payment->customer->code}",
                'lines'            => $lines,
            ]);

            $payment->update(['journal_entry_id' => $journalEntry->id]);
        });
    }

    // =====================================================
    // RETURN POSTING
    // =====================================================

    public function postReturn(SalesReturn $return): void
    {
        // Build reverse of the original invoice lines
        // DR Revenue, DR Tax Payable, CR AR
        // If restocked: DR Inventory, CR COGS
        // (implementation follows same pattern as postInvoice but reversed)
        DB::transaction(function () use ($return) {
            $lines = $this->buildReturnJournalLines($return);
            $this->validateBalanced($lines, "Return #{$return->return_number}");

            $journalEntry = $this->journalEntryService->create([
                'journal_id'       => $this->getSalesJournalId(),
                'entry_date'       => $return->return_date,
                'branch_id'        => $return->branch_id,
                'fiscal_year_id'   => $this->getFiscalYearId($return->return_date),
                'fiscal_period_id' => $this->getFiscalPeriodId($return->return_date),
                'reference'        => $return->return_number,
                'description'      => "Sales return: {$return->return_number}",
                'lines'            => $lines,
            ]);

            $return->update(['journal_entry_id' => $journalEntry->id]);
        });
    }

    // =====================================================
    // ACCOUNT RESOLUTION (with fallback chain)
    // =====================================================

    private function resolveARAccount(Customer $customer): int
    {
        return $customer->ar_account_id
            ?? $this->getSystemSetting('default_ar_account_id')
            ?? throw new \Exception('AR account not configured. Set customer AR account or default_ar_account_id in settings.');
    }

    private function resolveRevenueAccount(InvoiceLine $line): int
    {
        return $line->item->revenue_account_id
            ?? $line->item->category?->revenue_account_id
            ?? $this->getSystemSetting('default_revenue_account_id')
            ?? throw new \Exception("Revenue account not found for item: {$line->item->code}");
    }

    private function resolveCOGSAccount(Item $item): int
    {
        return $item->cogs_account_id
            ?? $item->category?->cogs_account_id
            ?? $this->getSystemSetting('default_cogs_account_id')
            ?? throw new \Exception("COGS account not found for item: {$item->code}");
    }

    private function resolveInventoryAccount(Item $item): int
    {
        return $item->inventory_account_id
            ?? $item->category?->inventory_account_id
            ?? $this->getSystemSetting('default_inventory_account_id')
            ?? throw new \Exception("Inventory account not found for item: {$item->code}");
    }

    private function resolveTaxPayableAccount(SalesTaxRate $taxRate): int
    {
        return $taxRate->tax_payable_account_id
            ?? $this->getSystemSetting('default_tax_payable_account_id')
            ?? throw new \Exception('Tax payable account not configured.');
    }

    // =====================================================
    // VALIDATION
    // =====================================================

    private function validateBalanced(array $lines, string $context): void
    {
        $totalDebit  = array_sum(array_column($lines, 'debit'));
        $totalCredit = array_sum(array_column($lines, 'credit'));

        if (abs($totalDebit - $totalCredit) > 0.01) {
            throw new \Exception("Journal entry not balanced for {$context}: DR={$totalDebit}, CR={$totalCredit}");
        }
    }

    // =====================================================
    // HELPERS
    // =====================================================

    private function getSystemSetting(string $key): ?int
    {
        return (int) Setting::where('key', $key)->value('value') ?: null;
    }

    private function getSalesJournalId(): int
    {
        return (int) Setting::where('key', 'sales_journal_id')->value('value')
            ?? throw new \Exception('Sales journal not configured in settings.');
    }

    private function getFiscalYearId(\Carbon\Carbon|string $date): int
    {
        // Resolve open fiscal year for the given date
    }

    private function getFiscalPeriodId(\Carbon\Carbon|string $date): int
    {
        // Resolve open fiscal period for the given date
    }
}
```

---

## System Settings Required

Add these keys to the `settings` table (via seeder or admin UI):

| Key | Purpose |
|-----|---------|
| `sales_journal_id` | Journal to use for all sales entries |
| `default_ar_account_id` | Default AR account (used if customer has no specific account) |
| `default_revenue_account_id` | Default revenue account |
| `default_cogs_account_id` | Default COGS account |
| `default_inventory_account_id` | Default inventory account |
| `default_tax_payable_account_id` | Default tax payable account |
| `sales_discount_account_id` | Account for sales discounts |

---

## Workflow to Expose in UI

On Invoice show page (when status = confirmed):
1. Show "Post to Finance" button
2. Clicking opens confirmation dialog showing the journal preview
3. Admin confirms → calls `SalesPostingService::postInvoice()`
4. On success: show journal entry number, update status badge

---

## Test This Task

**Test 1: Simple invoice posting**
- Invoice: 1 line, item=Widget, qty=10, price=100, tax=14%
- Expected journal:
  - DR Accounts Receivable   1,140
  - CR Revenue               1,000
  - CR Tax Payable             140
  - DR COGS                  [avg_cost × 10]
  - CR Inventory             [avg_cost × 10]
- Verify DR total = CR total

**Test 2: Invoice with discount**
- Invoice: subtotal=1,000, discount=100, tax on 900 = 126
- DR AR         1,026
- DR Discount     100
- CR Revenue    1,000
- CR Tax          126

**Test 3: Payment posting**
- DR Bank 500
- CR AR   500
- Verify invoice.balance_amount decreases by 500
