Skip to main content

Overview

The Accounting module implements a complete double-entry bookkeeping system that automatically generates journal entries from business transactions. It manages the chart of accounts, receivables, payments, and provides financial reports.

Double-Entry

Balanced journal entries

Automated

Entries from sales and payments

Receivables

Credit sales tracking

Core Components

Chart of Accounts

The foundation of the accounting system:
app/Models/Accounting/AccountingAccount.php
protected $fillable = [
    'code',          // e.g., "1.1.01", "4.1"
    'name',          // e.g., "Cash", "Sales Revenue"
    'type',          // asset, liability, equity, revenue, expense
    'subtype',       // current_asset, fixed_asset, etc.
    'is_active',
];
Common Account Structure:
1.1    Current Assets
1.1.01 Cash
1.1.02 Accounts Receivable
1.1.03 Inventory

1.2    Fixed Assets
1.2.01 Equipment
1.2.02 Vehicles

Journal Entries

Records all financial transactions:
app/Models/Accounting/JournalEntry.php
protected $fillable = [
    'entry_date',
    'reference',      // e.g., "FAC-2024-001"
    'description',
    'status',         // draft, posted, cancelled
    'created_by',
];

const STATUS_DRAFT     = 'draft';
const STATUS_POSTED    = 'posted';
const STATUS_CANCELLED = 'cancelled';
Entry Status Flow:

Journal Items

The individual debit and credit lines:
app/Models/Accounting/JournalItem.php
protected $fillable = [
    'journal_entry_id',
    'accounting_account_id',
    'debit',
    'credit',
];
Every journal entry must balance: Total Debits = Total Credits

Creating Journal Entries

Manual Entry

use App\Services\Accounting\JournalEntries\JournalEntryService;

$entryService = app(JournalEntryService::class);

$entry = $entryService->create([
    'entry_date'  => now(),
    'reference'   => 'MANUAL-001',
    'description' => 'Initial capital investment',
    'status'      => JournalEntry::STATUS_POSTED,
    'items' => [
        [
            'accounting_account_id' => 1,  // Cash account
            'debit'  => 50000,
            'credit' => 0,
        ],
        [
            'accounting_account_id' => 15, // Capital account
            'debit'  => 0,
            'credit' => 50000,
        ],
    ],
]);

Automatic from Sales

The system automatically generates entries for sales: Cash Sale:
app/Services/Sales/SalesServices/SaleService.php
protected function generateSaleAccountingEntry(Sale $sale)
{
    $incomeAccount = AccountingAccount::where('code', '4.1')->first();
    $debitAccountId = $sale->tipoPago?->accounting_account_id 
                    ?? AccountingAccount::where('code', '1.1.01')->value('id');

    $this->journalService->create([
        'entry_date'  => $sale->sale_date,
        'reference'   => $sale->number,
        'description' => "Venta Contado - {$sale->client->name}",
        'status'      => JournalEntry::STATUS_POSTED,
        'items' => [
            ['accounting_account_id' => $debitAccountId, 
             'debit' => $sale->total_amount, 'credit' => 0],
            ['accounting_account_id' => $incomeAccount->id, 
             'debit' => 0, 'credit' => $sale->total_amount]
        ]
    ]);
}
Result:
Date: 2024-03-05
Reference: FAC-2024-001
Description: Venta Contado - ABC Company

 Account              | Debit    | Credit
---------------------|----------|----------
 1.1.01 Cash          | $1,000   |
 4.1 Sales Revenue    |          | $1,000
---------------------|----------|----------
 Total                | $1,000   | $1,000   ✓ Balanced

Receivables Management

Tracks amounts owed by customers from credit sales.

Receivable Structure

app/Models/Accounting/Receivable.php
protected $fillable = [
    'client_id',
    'total_amount',
    'current_balance',
    'emission_date',
    'due_date',
    'status',              // unpaid, partial, paid, overdue
    'document_number',     // e.g., "FAC-2024-001"
    'reference_type',      // Polymorphic: Sale, etc.
    'reference_id',
];

const STATUS_UNPAID  = 'unpaid';
const STATUS_PARTIAL = 'partial';
const STATUS_PAID    = 'paid';
const STATUS_OVERDUE = 'overdue';

Creating Receivables

Automatically created from credit sales:
app/Services/Sales/SalesServices/SaleService.php
if ($sale->payment_type === Sale::PAYMENT_CREDIT) {
    $this->receivableService->createReceivable([
        'client_id'       => $sale->client_id,
        'total_amount'    => $sale->total_amount,
        'emission_date'   => $sale->sale_date,
        'due_date'        => $sale->sale_date->copy()->addDays(30),
        'document_number' => $sale->number,
        'reference_type'  => Sale::class,
        'reference_id'    => $sale->id,
        'description'     => "Venta a crédito"
    ]);
}

Applying Payments

app/Services/Accounting/Receivable/ReceivableService.php
public function applyPayment(Receivable $receivable, Payment $payment): void
{
    DB::transaction(function () use ($receivable, $payment) {
        // Reduce receivable balance
        $receivable->current_balance -= $payment->amount;
        $receivable->save();

        // Update status
        $this->updateStatusBasedOnBalance($receivable);

        // Link payment to receivable
        $payment->update([
            'receivable_id' => $receivable->id,
        ]);
    });
}
Status Updates:
if ($receivable->current_balance <= 0.01) {
    $receivable->status = Receivable::STATUS_PAID;
} elseif ($receivable->current_balance < $receivable->total_amount) {
    $receivable->status = Receivable::STATUS_PARTIAL;
} elseif (now()->isAfter($receivable->due_date)) {
    $receivable->status = Receivable::STATUS_OVERDUE;
}

Payment Processing

Recording Payments

app/Models/Accounting/Payment.php
protected $fillable = [
    'client_id',
    'receivable_id',
    'amount',
    'payment_date',
    'payment_method',      // cash, transfer, check, card
    'reference_number',    // Check/transfer number
    'notes',
];

Payment Workflow

1

Create Payment

Record the payment from the customer.
$payment = Payment::create([
    'client_id'      => $clientId,
    'amount'         => 500,
    'payment_date'   => now(),
    'payment_method' => 'transfer',
    'reference_number' => 'TRANS-12345',
]);
2

Apply to Receivable

Link the payment to an outstanding receivable.
$receivableService->applyPayment($receivable, $payment);
3

Generate Journal Entry

Create accounting entry for the payment.
Debit:  Cash/Bank (1.1.01)           $500
Credit: Accounts Receivable (1.1.02) $500
4

Update Client Balance

Refresh the client’s total outstanding balance.
$client->refreshBalance();

Document Types

Manages sequential numbering for financial documents:
app/Models/Accounting/DocumentType.php
protected $fillable = [
    'code',            // e.g., "FAC" for invoices
    'name',
    'prefix',          // e.g., "FAC-"
    'current_number',
    'padding',         // Number of digits
];

public function getNextNumberFormatted(): string
{
    $number = str_pad(
        $this->current_number + 1,
        $this->padding,
        '0',
        STR_PAD_LEFT
    );
    
    return $this->prefix . date('Y') . '-' . $number;
}
Example Output: FAC-2024-001, FAC-2024-002, etc.

Financial Reports

Trial Balance

$trialBalance = JournalItem::join('accounting_accounts', ...)
    ->selectRaw('
        accounting_account_id,
        SUM(debit) as total_debit,
        SUM(credit) as total_credit,
        SUM(debit) - SUM(credit) as balance
    ')
    ->groupBy('accounting_account_id')
    ->get();

Aging Report

$aging = Receivable::select([
    'client_id',
    DB::raw('SUM(CASE WHEN DATEDIFF(NOW(), due_date) <= 30 THEN current_balance ELSE 0 END) as current'),
    DB::raw('SUM(CASE WHEN DATEDIFF(NOW(), due_date) BETWEEN 31 AND 60 THEN current_balance ELSE 0 END) as days_31_60'),
    DB::raw('SUM(CASE WHEN DATEDIFF(NOW(), due_date) > 60 THEN current_balance ELSE 0 END) as over_60'),
])
->whereIn('status', [Receivable::STATUS_UNPAID, Receivable::STATUS_PARTIAL, Receivable::STATUS_OVERDUE])
->groupBy('client_id')
->get();

Income Statement

$revenue = JournalItem::whereHas('account', function($q) {
        $q->where('type', 'revenue');
    })
    ->whereBetween('created_at', [$startDate, $endDate])
    ->sum('credit');

$expenses = JournalItem::whereHas('account', function($q) {
        $q->where('type', 'expense');
    })
    ->whereBetween('created_at', [$startDate, $endDate])
    ->sum('debit');

$netIncome = $revenue - $expenses;

Best Practices

Validate that debits equal credits before posting:
$totalDebit = collect($items)->sum('debit');
$totalCredit = collect($items)->sum('credit');

if (abs($totalDebit - $totalCredit) > 0.01) {
    throw new \Exception('Entry is not balanced');
}
Create entries as drafts first, review, then post:
$entry = $entryService->create([
    'status' => JournalEntry::STATUS_DRAFT,
    // ...
]);

// After review
$entryService->post($entry);
Never delete posted entries. Create reversals instead:
protected function generateCancellationAccountingEntry(Sale $sale)
{
    // Create opposite entry
    $this->journalService->create([
        'reference'   => "REV-{$sale->number}",
        'description' => "Anulación Venta {$sale->number}",
        'items' => [
            // Flip debits and credits
        ]
    ]);
}
Periodically verify that:
  • Trial balance is balanced
  • Client balances match receivables
  • Bank statements match cash accounts

Integration Points

Sales Module

Generates entries for:
  • Cash sales (Debit: Cash, Credit: Revenue)
  • Credit sales (Debit: A/R, Credit: Revenue)
  • Sale cancellations (Reversal entries)

Inventory Module

Generates entries for:
  • Purchases (Debit: Inventory, Credit: A/P)
  • Cost of goods sold (Debit: COGS, Credit: Inventory)
  • Adjustments (Debit/Credit: Variance)

Payment Module

Generates entries for:
  • Customer payments (Debit: Cash, Credit: A/R)
  • Vendor payments (Debit: A/P, Credit: Cash)

Journal Entries

Detailed entry creation guide

Receivables

Credit sales management

Sales Module

Sales integration

Journal Entry Service

Service API reference

Build docs developers (and LLMs) love