feat(client): add financial reports and ledger management

- Add trial balance and general ledger pages to client resource with interactive tables
- Implement sales and expenses relation managers for client-specific transactions
- Enhance transaction handling with proper tax and withholding calculations
- Add date casting to Transaction model and define client relationships
- Configure super admin role bypass in AppServiceProvider
- Update Filament components and fix JavaScript formatting issues
This commit is contained in:
Jp
2026-02-09 16:20:55 +08:00
parent 91eb1fbe63
commit 207f4c1609
43 changed files with 3412 additions and 2967 deletions

View File

@@ -25,6 +25,7 @@ class CreateLedgerAction extends BaseAction
ledger_id: $ledger->id,
account_id: $ledger->account_id,
branch_id: $ledger->branch_id,
type: $payload->type ?? 'debit',
);
return $next($payload);

View File

@@ -30,6 +30,8 @@ class CreateTransactionAction extends BaseAction
public function transactionAccountLedger($payload): void
{
$branch = $payload->transaction->branch;
$isExpense = $payload->transactionable instanceof \App\Models\Expense;
$type = $isExpense ? 'debit' : 'credit';
if ($branch->isClientVatable) {
//create transaction account ledger
@@ -38,10 +40,11 @@ class CreateTransactionAction extends BaseAction
amount: $payload->transaction->net_amount ?? 0.00,
transaction: $payload->transaction,
account: $payload->transaction->account,
type: $type,
);
$this->ledgerPipe($ledgerPayload);
$this->inputTaxAccountLedger($payload);
$this->taxAccountLedger($payload, $isExpense);
} else {
//create transaction account ledger
$ledgerPayload = new CreateLedgerDTO(
@@ -49,6 +52,7 @@ class CreateTransactionAction extends BaseAction
amount: $payload->transaction->gross_amount ?? 0.00,
transaction: $payload->transaction,
account: $payload->transaction->account,
type: $type,
);
$this->ledgerPipe($ledgerPayload);
}
@@ -63,50 +67,69 @@ class CreateTransactionAction extends BaseAction
])->thenReturn();
}
public function inputTaxAccountLedger($payload): void
public function taxAccountLedger($payload, bool $isExpense): void
{
$inputTax = Account::query()->where('account', 'Input Tax')->whereHas('balances', function ($balance) use ($payload) {
$accountName = $isExpense ? 'Input Tax' : 'Output Tax';
$type = $isExpense ? 'debit' : 'credit';
$taxAccount = Account::query()->where('account', $accountName)->whereHas('balances', function ($balance) use ($payload) {
return $balance->where('branch_id', $payload->transactionable->branch_id);
})->first();
$ledgerPayload = new CreateLedgerDTO(
branch_id: $payload->transactionable->branch_id,
amount: $payload->transactionable->input_tax ?? 0.00,
transaction: $payload->transaction,
account: $inputTax,
);
$this->ledgerPipe($ledgerPayload);
if ($taxAccount) {
$ledgerPayload = new CreateLedgerDTO(
branch_id: $payload->transactionable->branch_id,
amount: $payload->transactionable->input_tax ?? 0.00, // Assuming input_tax holds the tax amount for both? Or output_tax?
transaction: $payload->transaction,
account: $taxAccount,
type: $type,
);
$this->ledgerPipe($ledgerPayload);
}
}
public function withHoldingAccountLedger($payload): void
{
$withholdingAccount = Account::query()->where('account', 'Payable Withholding Tax')->whereHas('balances', function ($balance) use ($payload) {
$isExpense = $payload->transactionable instanceof \App\Models\Expense;
$accountName = $isExpense ? 'Payable Withholding Tax' : 'Creditable Withholding Tax';
$type = $isExpense ? 'credit' : 'debit';
$withholdingAccount = Account::query()->where('account', $accountName)->whereHas('balances', function ($balance) use ($payload) {
return $balance->where('branch_id', $payload->transactionable->branch_id);
})->first();
$ledgerPayload = new CreateLedgerDTO(
branch_id: $payload->transactionable->branch_id,
amount: $payload->transaction->payable_withholding_tax ?? 0.00,
transaction: $payload->transaction,
account: $withholdingAccount,
);
$this->ledgerPipe($ledgerPayload);
if ($withholdingAccount) {
$ledgerPayload = new CreateLedgerDTO(
branch_id: $payload->transactionable->branch_id,
amount: $payload->transaction->payable_withholding_tax ?? 0.00,
transaction: $payload->transaction,
account: $withholdingAccount,
type: $type,
);
$this->ledgerPipe($ledgerPayload);
}
}
public function cashAccountLedger($payload): void
{
$isExpense = $payload->transactionable instanceof \App\Models\Expense;
$type = $isExpense ? 'credit' : 'debit';
$wht = $isExpense ? ($payload->transaction->payable_withholding_tax ?? 0) : ($payload->transaction->creditable_withholding_tax ?? 0);
$amount = ($payload->transaction->gross_amount ?? 0) - $wht;
$cashAccount = Account::query()->where('account', 'Cash')->whereHas('balances', function ($balance) use ($payload) {
return $balance->where('branch_id', $payload->transactionable->branch_id);
})->first();
$amount = $payload->transaction->gross_amount - $payload->transaction->creditable_withholding_tax;
$ledgerPayload = new CreateLedgerDTO(
branch_id: $payload->transactionable->branch_id,
amount: $amount ?? 0.00,
transaction: $payload->transaction,
account: $cashAccount,
);
$this->ledgerPipe($ledgerPayload);
if ($cashAccount) {
$ledgerPayload = new CreateLedgerDTO(
branch_id: $payload->transactionable->branch_id,
amount: $amount,
transaction: $payload->transaction,
account: $cashAccount,
type: $type,
);
$this->ledgerPipe($ledgerPayload);
}
}
}

View File

@@ -15,16 +15,27 @@ class CreateBalanceDTO extends Data
public ?int $id = null,
public ?int $ledger_id = null,
public ?int $account_id = null,
public ?int $branch_id = null
public ?int $branch_id = null,
public string $type = 'debit'
) {
$account = Account::query()->where('id', $this->account_id)->first();
$account = Account::with('accountType')->where('id', $this->account_id)->first();
$currentBalance = $account ? $account->current_balance : 0;
$normalBalance = strtolower($account->accountType->normal_balance ?? 'debit');
$transactionType = strtolower($this->type);
if ($account->normal_balance == 'credit') {
$this->balance = $currentBalance - $this->amount;
} else {
$this->balance = $currentBalance + $this->amount;
if ($transactionType === 'debit') {
if ($normalBalance === 'debit') {
$this->balance = $currentBalance + $this->amount;
} else {
$this->balance = $currentBalance - $this->amount;
}
} else { // credit
if ($normalBalance === 'credit') {
$this->balance = $currentBalance + $this->amount;
} else {
$this->balance = $currentBalance - $this->amount;
}
}
}
}

View File

@@ -13,7 +13,10 @@ class CreateLedgerDTO extends Data
public array $data;
#[Computed]
public int $transaction_id;
public ?int $transaction_id = null;
#[Computed]
public ?int $journal_id = null;
#[Computed]
public int $account_id;
@@ -35,16 +38,22 @@ class CreateLedgerDTO extends Data
public float $amount,
public ?Model $ledger = null,
public ?Model $transaction = null,
public ?Model $journal = null,
public ?Model $account = null,
public ?CreateBalanceDTO $balanceDTO = null,
public ?string $type = null, // 'debit' or 'credit'
) {
$this->transaction_id = $this->transaction->id;
$this->transaction_id = $this->transaction?->id;
$this->journal_id = $this->journal?->id;
$this->account_id = $this->account->id;
$this->client_id = $this->transaction->branch->client_id;
$this->credit_amount = $this->transaction->account_type == 'credit' ? $this->amount : 0.00;
$this->debit_amount = $this->transaction->account_type == 'debit' ? $this->amount : 0.00;
$this->description = $this->transaction->description;
$this->client_id = $this->transaction?->branch->client_id ?? $this->journal?->client_id;
$accountType = $this->type ? strtolower($this->type) : strtolower($this->transaction?->account_type);
$this->data = Arr::except($this->toArray(), ['transaction', 'ledger', 'account', 'amount']);
$this->credit_amount = $accountType == 'credit' ? $this->amount : 0.00;
$this->debit_amount = $accountType == 'debit' ? $this->amount : 0.00;
$this->description = $this->transaction?->description ?? $this->journal?->description;
$this->data = Arr::except($this->toArray(), ['transaction', 'journal', 'ledger', 'account', 'amount', 'type']);
}
}

View File

@@ -1,12 +0,0 @@
<?php
namespace App\Filament\Pages;
use Filament\Pages\Page;
class GeneralLedger extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static string $view = 'filament.pages.general-ledger';
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Filament\Pages;
use Filament\Pages\Page;
class TrialBalance extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static string $view = 'filament.pages.trial-balance';
public function getViewData(): array
{
return [];
}
}

View File

@@ -6,8 +6,13 @@ use App\DataObjects\CreateBranchDTO;
use App\Filament\Resources\ClientResource\Pages\EditClient;
use App\Filament\Resources\ClientResource\Pages\ListClients;
use App\Filament\Resources\ClientResource\Pages\ViewClient;
use App\Filament\Resources\ClientResource\Pages\GeneralLedger;
use App\Filament\Resources\ClientResource\Pages\TrialBalance;
use App\Filament\Resources\ClientResource\RelationManagers\AccountsRelationManager;
use App\Filament\Resources\ClientResource\RelationManagers\BranchesRelationManager;
use App\Filament\Resources\ClientResource\RelationManagers\ExpensesRelationManager;
use App\Filament\Resources\ClientResource\RelationManagers\JournalsRelationManager;
use App\Filament\Resources\ClientResource\RelationManagers\SalesRelationManager;
use App\Filament\Resources\ClientResource\RelationManagers\TransmittalsRelationManager;
use App\Models\Branch;
use App\Models\Client;
@@ -19,6 +24,8 @@ use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Filament\Pages\SubNavigationPosition;
use Filament\Resources\Pages\Page;
class ClientResource extends Resource
{
@@ -26,11 +33,25 @@ class ClientResource extends Resource
protected static ?string $navigationIcon = 'heroicon-o-user';
protected static ?string $recordTitleAttribute = 'company';
protected static SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Top;
public static function authorizeView(Model $record): void
{
parent::authorizeView($record);
}
public static function getRecordSubNavigation(Page $page): array
{
return $page->generateNavigationItems([
ViewClient::class,
EditClient::class,
GeneralLedger::class,
TrialBalance::class,
]);
}
public static function form(Form $form): Form
{
return $form
@@ -85,6 +106,9 @@ class ClientResource extends Resource
AccountsRelationManager::class,
BranchesRelationManager::class,
TransmittalsRelationManager::class,
SalesRelationManager::class,
ExpensesRelationManager::class,
JournalsRelationManager::class,
];
}
@@ -94,6 +118,8 @@ class ClientResource extends Resource
'view' => ViewClient::route('/{record}'),
'edit' => EditClient::route('/{record}/edit'),
'index' => ListClients::route('/'),
'general-ledger' => GeneralLedger::route('/{record}/general-ledger'),
'trial-balance' => TrialBalance::route('/{record}/trial-balance'),
];
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource;
use App\Models\Ledger;
use Filament\Resources\Pages\Page;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\Filter;
use Filament\Forms\Components\DatePicker;
use Illuminate\Database\Eloquent\Builder;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use pxlrbt\FilamentExcel\Actions\Tables\ExportAction;
use pxlrbt\FilamentExcel\Exports\ExcelExport;
use pxlrbt\FilamentExcel\Columns\Column;
class GeneralLedger extends Page implements HasTable
{
use InteractsWithTable;
use InteractsWithRecord;
protected static string $resource = ClientResource::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static string $view = 'filament.resources.client-resource.pages.general-ledger';
public function mount(int | string $record): void
{
$this->record = $this->resolveRecord($record);
}
public function table(Table $table): Table
{
return $table
->query(
Ledger::query()
->where('client_id', $this->getRecord()->id)
->with(['account', 'transaction', 'journal'])
)
->columns([
TextColumn::make('date')
->label('Date')
->state(function (Ledger $record) {
return $record->transaction?->happened_on?->format('Y-m-d')
?? $record->journal?->happened_on?->format('Y-m-d')
?? $record->created_at->format('Y-m-d');
})
->sortable(),
TextColumn::make('account.account')
->label('Account')
->searchable()
->sortable(),
TextColumn::make('description')
->limit(50)
->tooltip(function (TextColumn $column): ?string {
$state = $column->getState();
if (strlen($state) <= $column->getCharacterLimit()) {
return null;
}
return $state;
}),
TextColumn::make('debit_amount')
->label('Debit')
->money('PHP')
->sortable(),
TextColumn::make('credit_amount')
->label('Credit')
->money('PHP')
->sortable(),
])
->filters([
SelectFilter::make('account')
->relationship('account', 'account', fn (Builder $query) => $query->where('client_id', $this->getRecord()->id))
->searchable()
->preload(),
Filter::make('date_range')
->form([
DatePicker::make('from'),
DatePicker::make('to'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['from'],
fn (Builder $query, $date): Builder => $query->where(function ($q) use ($date) {
$q->whereHas('transaction', fn ($q) => $q->whereDate('happened_on', '>=', $date))
->orWhereHas('journal', fn ($q) => $q->whereDate('happened_on', '>=', $date))
->orWhere(fn($q) => $q->whereDoesntHave('transaction')->whereDoesntHave('journal')->whereDate('created_at', '>=', $date));
})
)
->when(
$data['to'],
fn (Builder $query, $date): Builder => $query->where(function ($q) use ($date) {
$q->whereHas('transaction', fn ($q) => $q->whereDate('happened_on', '<=', $date))
->orWhereHas('journal', fn ($q) => $q->whereDate('happened_on', '<=', $date))
->orWhere(fn($q) => $q->whereDoesntHave('transaction')->whereDoesntHave('journal')->whereDate('created_at', '<=', $date));
})
);
})
])
->defaultSort('created_at', 'desc')
->groups([
'account.account',
])
->headerActions([
ExportAction::make()
->label('Export General Ledger')
->exports([
ExcelExport::make()
->fromTable()
->withFilename(fn () => $this->getRecord()->name . ' - General Ledger - ' . date('Y-m-d'))
->withColumns([
Column::make('date')
->heading('Date')
->formatStateUsing(fn ($record) => $record->transaction?->happened_on?->format('Y-m-d')
?? $record->journal?->happened_on?->format('Y-m-d')
?? $record->created_at->format('Y-m-d')),
Column::make('reference')
->heading('Reference')
->formatStateUsing(fn ($record) => match(true) {
$record->transaction && $record->transaction->transactionable =>
$record->transaction->transactionable->voucher_number
?? $record->transaction->transactionable->reference_number
?? 'TR-'.$record->transaction->id,
$record->transaction => 'TR-'.$record->transaction->id,
$record->journal => $record->journal->series ?? 'JV-'.$record->journal->id,
default => 'L-'.$record->id,
}),
Column::make('account.account')->heading('Account'),
Column::make('description')->heading('Description'),
Column::make('debit_amount')->heading('Debit'),
Column::make('credit_amount')->heading('Credit'),
])
])
]);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource;
use App\Models\Account;
use Filament\Resources\Pages\Page;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\Summarizers\Summarizer;
use Filament\Tables\Columns\Summarizers\Sum;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Illuminate\Database\Eloquent\Builder;
class TrialBalance extends Page implements HasTable
{
use InteractsWithTable;
use InteractsWithRecord;
protected static string $resource = ClientResource::class;
protected static ?string $navigationIcon = 'heroicon-o-scale';
protected static string $view = 'filament.resources.client-resource.pages.trial-balance';
public function mount(int | string $record): void
{
$this->record = $this->resolveRecord($record);
}
public function table(Table $table): Table
{
return $table
->query(
Account::query()
->where('client_id', $this->getRecord()->id)
->with(['accountType', 'latestBalance'])
)
->columns([
TextColumn::make('account')
->label('Account Name')
->sortable()
->searchable(),
TextColumn::make('debit')
->label('Debit')
->money('PHP')
->state(function (Account $record) {
$balance = $record->latestBalance?->balance ?? 0;
$normal = strtolower($record->accountType->normal_balance ?? 'debit');
return ($normal == 'debit' && $balance >= 0) || ($normal == 'credit' && $balance < 0)
? abs($balance) : 0;
})
->summarize(Summarizer::make()
->label('Total Debit')
->money('PHP')
->using(function ($query) {
return Account::query()
->whereIn('id', $query->clone()->pluck('accounts.id'))
->with(['accountType', 'latestBalance'])
->get()
->sum(function ($record) {
$balance = $record->latestBalance?->balance ?? 0;
$normal = strtolower($record->accountType->normal_balance ?? 'debit');
return ($normal == 'debit' && $balance >= 0) || ($normal == 'credit' && $balance < 0)
? abs($balance) : 0;
});
})
),
TextColumn::make('credit')
->label('Credit')
->money('PHP')
->state(function (Account $record) {
$balance = $record->latestBalance?->balance ?? 0;
$normal = strtolower($record->accountType->normal_balance ?? 'debit');
return ($normal == 'credit' && $balance >= 0) || ($normal == 'debit' && $balance < 0)
? abs($balance) : 0;
})
->summarize(Summarizer::make()
->label('Total Credit')
->money('PHP')
->using(function ($query) {
return Account::query()
->whereIn('id', $query->clone()->pluck('accounts.id'))
->with(['accountType', 'latestBalance'])
->get()
->sum(function ($record) {
$balance = $record->latestBalance?->balance ?? 0;
$normal = strtolower($record->accountType->normal_balance ?? 'debit');
return ($normal == 'credit' && $balance >= 0) || ($normal == 'debit' && $balance < 0)
? abs($balance) : 0;
});
})
),
]);
}
}

View File

@@ -2,38 +2,238 @@
namespace App\Filament\Resources\ClientResource\RelationManagers;
use App\Actions\Transactions\CreateTransactionAction;
use App\DataObjects\CreateTransactionDTO;
use App\Models\Account;
use App\Models\Branch;
use App\Models\Client;
use Awcodes\TableRepeater\Components\TableRepeater;
use Awcodes\TableRepeater\Header;
use Filament\Forms;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Pipeline;
class ExpensesRelationManager extends RelationManager
{
protected static string $relationship = 'expenses';
protected static ?string $title = 'Expenses';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('client_id')
Select::make('branch_id')
->relationship('branch', 'code', fn (Builder $query) => $query->where('client_id', $this->getOwnerRecord()->id))
->afterStateUpdated(function ($set, $get) {
$set('voucher_number', static::getVoucherNumber($get));
$set('transactions.*.branch_id', $get('branch_id'));
})
->required()
->maxLength(255),
->live(),
TextInput::make('supplier')->label('Supplier Name')->required(),
TextInput::make('reference_number')->label('Reference Number'),
TextInput::make('voucher_number')->label('Voucher Number')
->default(fn ($get) => static::getVoucherNumber($get))
->readOnly(),
DatePicker::make('happened_on')->label('Date')
->required()
->afterStateUpdated(function ($set, $get) {
$set('transactions.*.happened_on', $get('happened_on'));
})
->live()
->native(false),
TableRepeater::make('transactions')
->headers(fn (Get $get): array => static::getTransactionTableHeader($get))
->relationship('transactions')
->schema(fn (Get $get): array => $this->getTransactionTableFormSchema($get))
->visible(fn (Get $get) => $get('branch_id') != null)
->columnSpan('full'),
]);
}
public static function getVoucherNumber(Get $get): string
{
$branch = Branch::find($get('branch_id'));
if ($branch) {
return GenerateVoucher::execute($branch);
}
return '';
}
private static function getTransactionTableHeader(Get $get): array
{
return [
Header::make('Charge Account'),
Header::make('Description'),
Header::make('Gross Amount'),
Header::make('Exempt'),
Header::make('Zero Rated'),
Header::make('Vatable Amount'),
Header::make('Input Tax'),
Header::make('Withholding Tax'),
Header::make('Net Amount'),
];
}
private function getTransactionTableFormSchema(Get $get): array
{
return [
Select::make('account_id')->options(fn ($get) => $this->getAccountOptions($get)),
TextInput::make('description')->label('Description'),
Hidden::make('branch_id')->default(fn (Get $get) => $get('../../branch_id')),
TextInput::make('gross_amount')
->numeric()
->live(false, 500)
->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
$this->setDefaultFormValues($get, $set, $old, $state);
})->default(0),
TextInput::make('exempt')
->numeric()
->live()
->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
$this->setDefaultFormValues($get, $set, $old, $state);
})->default(0),
TextInput::make('zero_rated')
->numeric()
->live()
->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
$this->setDefaultFormValues($get, $set, $old, $state);
})->default(0),
TextInput::make('vatable_amount')
->numeric()
->nullable()
->live()
->readOnly()
->default(0),
Hidden::make('happened_on')->default(fn (Get $get) => $get('../../happened_on')),
TextInput::make('input_tax')
->numeric()
->live()
->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
$this->setDefaultFormValues($get, $set, $old, $state);
})->default(0),
TextInput::make('payable_withholding_tax')
->numeric()
->live()
->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
$this->setDefaultFormValues($get, $set, $old, $state);
})->default(0),
TextInput::make('net_amount')->numeric()->default(0),
];
}
private function getAccountOptions(Get $get): Collection
{
$query = Account::query();
$query->where([
'client_id' => $this->getOwnerRecord()->id,
]);
if ($get('../../branch_id')) {
$query->whereHas('balances', function ($query) use ($get) {
return $query->where('branch_id', $get('../../branch_id'));
});
}
$query->whereHas('accountType', function ($query) {
return $query->where('type', 'Expenses');
});
return $query->get()->pluck('account', 'id');
}
private function setDefaultFormValues(Get $get, Set $set, ?string $old, ?string $state): void
{
$exempt = (float) $get('exempt');
$withHoldingTax = (float) $get('payable_withholding_tax');
$vatableSales = $get('gross_amount');
$vatableAmount = 0;
if ($vatableSales) {
$vatableAmount = $vatableSales / 1.12;
}
$inputTax = $vatableAmount * 0.12;
$netAmount = (int) $vatableSales - $get('payable_withholding_tax');
if ($this->getOwnerRecord()->vatable) {
$netAmount = ($vatableAmount + $exempt) - $withHoldingTax;
}
$set('input_tax', number_format($inputTax, 2, '.', ''));
$set('vatable_amount', number_format($vatableAmount, 2, '.', ''));
$set('net_amount', number_format($netAmount, 2, '.', ''));
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('client_id')
->recordTitleAttribute('supplier')
->columns([
Tables\Columns\TextColumn::make('client_id'),
TextColumn::make('supplier'),
TextColumn::make('reference_number'),
TextColumn::make('voucher_number'),
TextColumn::make('branch.code'),
TextColumn::make('happened_on'),
])
->filters([
//
])
->headerActions([
Tables\Actions\CreateAction::make(),
Tables\Actions\CreateAction::make()
->using(function (array $data, string $model) {
$transactions = $data['transactions'] ?? [];
$data = Arr::except($data, ['transactions']);
$record = $model::create($data);
try {
$branch = $record->branch;
foreach ($transactions as $transaction) {
$tData = [
'branch_id' => $branch->id,
'happened_on' => $record->happened_on,
...$transaction,
];
$payload = new CreateTransactionDTO(data: $tData, transactionable: $record);
Pipeline::send(passable: $payload)->through(
[
CreateTransactionAction::class,
]
)->thenReturn();
}
} catch (\Exception $exception) {
throw new \Exception('Failed to save transactions : '.$exception->getMessage());
}
return $record;
}),
])
->actions([
Tables\Actions\EditAction::make(),

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Filament\Resources\ClientResource\RelationManagers;
use App\Actions\Balances\CreateBalanceAction;
use App\Actions\Ledgers\CreateLedgerAction;
use App\DataObjects\CreateLedgerDTO;
use App\Models\Branch;
use App\Models\Journal;
use Awcodes\TableRepeater\Components\TableRepeater;
use Awcodes\TableRepeater\Header;
use Filament\Forms;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Pipeline;
class JournalsRelationManager extends RelationManager
{
protected static string $relationship = 'journals';
protected static ?string $title = 'Journal Entries (Adjustments)';
public function form(Form $form): Form
{
return $form
->schema([
Select::make('branch_id')
->label('Branch')
->options(fn () => Branch::where('client_id', $this->getOwnerRecord()->id)->pluck('name', 'id'))
->required()
->default(fn () => Branch::where('client_id', $this->getOwnerRecord()->id)->first()?->id),
DatePicker::make('happened_on')
->label('Date')
->required()
->default(now()),
TextInput::make('series')
->label('Reference/Series #')
->required(),
Textarea::make('description')
->label('Description')
->columnSpanFull(),
TableRepeater::make('ledgers')
->relationship('ledgers')
->headers([
Header::make('Account'),
Header::make('Description'),
Header::make('Debit'),
Header::make('Credit'),
])
->schema([
Select::make('account_id')
->relationship('account', 'account', fn (Builder $query) => $query->where('client_id', $this->getOwnerRecord()->id))
->searchable()
->preload()
->required(),
TextInput::make('description'),
TextInput::make('debit_amount')
->numeric()
->default(0),
TextInput::make('credit_amount')
->numeric()
->default(0),
])
->columnSpanFull()
->minItems(2)
->live()
->rules([
fn (): \Closure => function (string $attribute, $value, \Closure $fail) {
$debit = collect($value)->sum('debit_amount');
$credit = collect($value)->sum('credit_amount');
if (abs($debit - $credit) > 0.01) {
$fail("Total Debit (" . number_format($debit, 2) . ") must equal Total Credit (" . number_format($credit, 2) . ").");
}
},
])
->afterStateUpdated(function ($state, $component) {
// Optional: Validation logic
}),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('description')
->columns([
Tables\Columns\TextColumn::make('happened_on')
->date()
->sortable(),
Tables\Columns\TextColumn::make('series')
->searchable(),
Tables\Columns\TextColumn::make('description')
->limit(50),
Tables\Columns\TextColumn::make('total_debit')
->label('Total Debit')
->state(fn (Journal $record) => $record->ledgers->sum('debit_amount'))
->money('PHP'),
Tables\Columns\TextColumn::make('total_credit')
->label('Total Credit')
->state(fn (Journal $record) => $record->ledgers->sum('credit_amount'))
->money('PHP'),
])
->filters([
//
])
->headerActions([
Tables\Actions\CreateAction::make()
->label('Add Adjustment Entry')
->using(function (array $data, string $model) {
return DB::transaction(function () use ($data, $model) {
$ledgersData = $data['ledgers'] ?? [];
$journalData = Arr::except($data, ['ledgers']);
$journalData['client_id'] = $this->getOwnerRecord()->id;
$journal = $model::create($journalData);
foreach ($ledgersData as $ledger) {
$ledgerPayload = new CreateLedgerDTO(
branch_id: $journal->branch_id,
amount: ($ledger['debit_amount'] > 0) ? $ledger['debit_amount'] : $ledger['credit_amount'],
ledger: null, // Will be created
transaction: null, // No transaction
journal: $journal,
account: \App\Models\Account::find($ledger['account_id']),
type: ($ledger['debit_amount'] > 0) ? 'debit' : 'credit'
);
Pipeline::send(passable: $ledgerPayload)->through(
[
CreateLedgerAction::class,
]
)->thenReturn();
}
return $journal;
});
}),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,275 @@
<?php
namespace App\Filament\Resources\ClientResource\RelationManagers;
use App\Actions\Transactions\CreateTransactionAction;
use App\DataObjects\CreateTransactionDTO;
use Illuminate\Support\Facades\Pipeline;
use App\Models\Account;
use App\Models\Branch;
use App\Models\Client;
use Awcodes\TableRepeater\Components\TableRepeater;
use Awcodes\TableRepeater\Header;
use Filament\Forms;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
class SalesRelationManager extends RelationManager
{
protected static string $relationship = 'sales';
protected static ?string $title = 'Sales';
public function form(Form $form): Form
{
return $form
->schema([
Select::make('branch_id')
->relationship('branch', 'code', fn (Builder $query) => $query->where('client_id', $this->getOwnerRecord()->id))
->required()
->afterStateUpdated(function ($set, $get) {
$set('current_series', static::getSeries($get));
$set('transactions.*.branch_id', $get('branch_id'));
})
->live(),
TextInput::make('current_series')
->label('Series')
->disabled(),
DatePicker::make('happened_on')->label('Date')
->required()
->afterStateUpdated(function ($set, $get) {
$set('transactions.*.happened_on', $get('happened_on'));
})
->native(false),
Checkbox::make('with_discount')->label('With Discount?')->default(false)->live(),
TableRepeater::make('transactions')
->headers(fn (Get $get): array => static::getTransactionTableHeader($get))
->relationship('transactions')
->schema(fn (Get $get): array => $this->getTransactionTableFormSchema($get))
->visible(fn (Get $get) => $get('branch_id') != null)
->columnSpan('full'),
]);
}
public static function getSeries(Get $get): string
{
$branch = Branch::find($get('branch_id'));
if ($branch) {
$currentSeries = $branch->current_series;
return str_pad($currentSeries + 1, 6, '0', STR_PAD_LEFT);
}
return '';
}
private static function getTransactionTableHeader(Get $get): array
{
if ($get('with_discount')) {
return [
Header::make('Charge Account'),
Header::make('Description'),
Header::make('Gross Amount'),
Header::make('Exempt'),
Header::make('Vatable Amount'),
Header::make('Output Tax'),
Header::make('Withholding Tax'),
Header::make('Discount'),
Header::make('Net Amount'),
];
}
return [
Header::make('Charge Account'),
Header::make('Description'),
Header::make('Gross Amount'),
Header::make('Exempt'),
Header::make('Vatable Amount'),
Header::make('Output Tax'),
Header::make('Withholding Tax'),
Header::make('Net Amount'),
];
}
private function getTransactionTableFormSchema(Get $get): array
{
return [
Select::make('account_id')->options(fn ($get) => $this->getAccountOptions($get)),
TextInput::make('description')->label('Description'),
Hidden::make('branch_id')->default(fn (Get $get) => $get('../../branch_id')),
TextInput::make('gross_amount')
->numeric()
->live(false, 500)
->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
$this->setDefaultFormValues($get, $set, $old, $state);
})->default(0),
TextInput::make('exempt')
->numeric()
->live()
->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
$this->setDefaultFormValues($get, $set, $old, $state);
})->default(0),
TextInput::make('vatable_amount')
->numeric()
->nullable()
->live()
->readOnly()
->default(0),
Hidden::make('happened_on')->default(fn (Get $get) => $get('../../happened_on')),
Hidden::make('with_discount')->default(fn (Get $get) => $get('../../with_discount')),
TextInput::make('output_tax')
->numeric()
->live()
->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
$this->setDefaultFormValues($get, $set, $old, $state);
})->default(0),
TextInput::make('payable_withholding_tax')
->numeric()
->live()
->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
$this->setDefaultFormValues($get, $set, $old, $state);
})->default(0),
TextInput::make('discount')
->numeric()
->readOnly()
->visible(fn (Get $get) => $get('../../with_discount'))
->live(),
TextInput::make('net_amount')->numeric()->default(0),
];
}
private function getAccountOptions($get)
{
$query = Account::query();
$query->where([
'client_id' => $this->getOwnerRecord()->id,
]);
if ($get('../../branch_id')) {
$query->whereHas('balances', function ($query) use ($get) {
return $query->where('branch_id', $get('../../branch_id'));
});
}
$query->whereHas('accountType', function ($query) {
return $query->where('type', 'Revenue');
});
return $query->get()->pluck('account', 'id');
}
private function setDefaultFormValues(Get $get, Set $set, ?string $old, ?string $state)
{
$exempt = (float) $get('exempt');
$withHoldingTax = (float) $get('payable_withholding_tax');
$vatableSales = $get('gross_amount');
$vatableAmount = 0;
if ($vatableSales) {
$vatableAmount = $vatableSales / 1.12;
}
$discount = $exempt * .20;
$outputTax = $vatableAmount * 0.12;
//default net amount
$netAmount = (int) $vatableSales - $get('payable_withholding_tax');
//net amount if vatable
if ($this->getOwnerRecord()->vatable) {
$netAmount = ($vatableAmount + $exempt) - $withHoldingTax;
}
//if discounted
if ($get('../../with_discount')) {
$netAmount = $netAmount - $discount;
}
$set('output_tax', number_format($outputTax, 2, '.', ''));
$set('discount', number_format($discount, 2, '.', ''));
$set('vatable_amount', number_format($vatableAmount, 2, '.', ''));
$set('net_amount', number_format($netAmount, 2, '.', ''));
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('title')
->columns([
TextColumn::make('id')->label('ID')->sortable(),
TextColumn::make('branch.code')->label('Branch')->sortable(),
TextColumn::make('happened_on')->label('Date')->date()->sortable(),
TextColumn::make('gross_amount')->label('Gross Amount')->numeric()->sortable(),
TextColumn::make('exempt')->label('Exempt')->numeric()->sortable(),
TextColumn::make('vatable_amount')->label('Vatable Amount')->numeric()->sortable(),
TextColumn::make('output_tax')->label('Output Tax')->numeric()->sortable(),
TextColumn::make('payable_withholding_tax')->label('Payable Withholding Tax')->numeric()->sortable(),
TextColumn::make('discount')->label('Discount')->numeric()->sortable(),
TextColumn::make('net_amount')->label('Net Amount')->numeric()->sortable(),
])
->filters([
//
])
->headerActions([
Tables\Actions\CreateAction::make()
->using(function (array $data, string $model) {
$transactions = $data['transactions'] ?? [];
$data = Arr::except($data, ['transactions']);
$record = $model::create($data);
try {
$branch = $record->branch;
foreach ($transactions as $transaction) {
$tData = [
'branch_id' => $branch->id,
'happened_on' => $record->happened_on,
...$transaction,
];
$payload = new CreateTransactionDTO(data: $tData, transactionable: $record);
Pipeline::send(passable: $payload)->through(
[
CreateTransactionAction::class,
]
)->thenReturn();
}
} catch (\Exception $exception) {
throw new \Exception('Failed to save transactions : '.$exception->getMessage());
}
return $record;
}),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -29,7 +29,9 @@ class ExpenseResource extends Resource
protected static bool $isVatable;
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?string $navigationIcon = 'heroicon-o-banknotes';
protected static bool $shouldRegisterNavigation = false;
public static function form(Form $form): Form
{

View File

@@ -19,13 +19,16 @@ use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class SaleResource extends Resource
{
protected static ?string $model = Sale::class;
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?string $navigationIcon = 'heroicon-o-currency-dollar';
protected static bool $shouldRegisterNavigation = false;
public static function form(Form $form): Form
{
@@ -215,7 +218,17 @@ class SaleResource extends Resource
{
return $table
->columns([
//
TextColumn::make('id')->label('ID')->sortable(),
TextColumn::make('client.name')->label('Client')->sortable(),
TextColumn::make('branch.name')->label('Branch')->sortable(),
TextColumn::make('happened_on')->label('Date')->date()->sortable(),
TextColumn::make('gross_amount')->label('Gross Amount')->numeric()->sortable(),
TextColumn::make('exempt')->label('Exempt')->numeric()->sortable(),
TextColumn::make('vatable_amount')->label('Vatable Amount')->numeric()->sortable(),
TextColumn::make('output_tax')->label('Output Tax')->numeric()->sortable(),
TextColumn::make('payable_withholding_tax')->label('Payable Withholding Tax')->numeric()->sortable(),
TextColumn::make('discount')->label('Discount')->numeric()->sortable(),
TextColumn::make('net_amount')->label('Net Amount')->numeric()->sortable(),
])
->filters([
//

View File

@@ -24,6 +24,5 @@ class CreateSale extends CreateRecord
protected function afterCreate(): void
{
$branch = Branch::find($this->data['branch_id']);
dd($branch);
}
}

View File

@@ -47,6 +47,16 @@ class Account extends Model
return $this->hasMany(Balance::class);
}
public function latestBalance(): \Illuminate\Database\Eloquent\Relations\HasOne
{
return $this->hasOne(Balance::class)->latestOfMany();
}
public function ledgers(): HasMany
{
return $this->hasMany(Ledger::class);
}
public function getCurrentBalanceAttribute()
{
if ($this->balances()->exists()) {

View File

@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
#[ObservedBy([ClientObserver::class])]
class Client extends Model
@@ -62,4 +63,14 @@ class Client extends Model
{
return $this->hasMany(Transmittal::class);
}
public function sales(): HasManyThrough
{
return $this->hasManyThrough(Sale::class, Branch::class);
}
public function expenses(): HasManyThrough
{
return $this->hasManyThrough(Expense::class, Branch::class);
}
}

View File

@@ -7,7 +7,7 @@ use App\Traits\HasUser;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Journal extends Model
{
@@ -17,9 +17,9 @@ class Journal extends Model
protected $casts = ['happened_on' => 'date'];
public function ledger(): HasOne
public function ledgers(): HasMany
{
return $this->hasOne(Ledger::class);
return $this->hasMany(Ledger::class);
}
public function scopeDateCreatedFilter(Builder $query, $date) : Builder

View File

@@ -15,6 +15,10 @@ class Transaction extends Model
protected $guarded = [];
protected $casts = [
'happened_on' => 'date',
];
public function transactionable(): MorphTo
{
return $this->morphTo();

View File

@@ -22,6 +22,10 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
Gate::before(function ($user, $ability) {
return $user->hasRole('super_admin') ? true : null;
});
Gate::policy(Role::class, RolePolicy::class);
}
}