fiscalYearStartDate = $company->locale->fiscalYearStartDate();
$this->fiscalYearEndDate = $company->locale->fiscalYearEndDate();
}
public static function getModel(): string
{
return static::$model;
}
public static function getEloquentQuery(): Builder
{
return static::getModel()::query();
}
protected function getHeaderActions(): array
{
return [
$this->buildTransactionAction('addIncome', 'Add Income', TransactionType::Deposit),
$this->buildTransactionAction('addExpense', 'Add Expense', TransactionType::Withdrawal),
Actions\ActionGroup::make([
Actions\CreateAction::make('addJournalTransaction')
->label('Add Journal Transaction')
->fillForm(fn (): array => $this->getFormDefaultsForType(TransactionType::Journal))
->modalWidth(MaxWidth::Screen)
->model(static::getModel())
->form(fn (Form $form) => $this->journalTransactionForm($form))
->modalSubmitAction(fn (Actions\StaticAction $action) => $action->disabled(! $this->isJournalEntryBalanced()))
->groupedIcon(null)
->modalHeading('Journal Entry')
->mutateFormDataUsing(static fn (array $data) => array_merge($data, ['type' => TransactionType::Journal]))
->afterFormFilled(fn () => $this->resetJournalEntryAmounts()),
Actions\Action::make('connectBank')
->label('Connect Your Bank')
->url(ConnectedAccount::getUrl()),
])
->label('More')
->button()
->outlined()
->dropdownWidth('max-w-fit')
->dropdownPlacement('bottom-end')
->icon('heroicon-c-chevron-down')
->iconSize(IconSize::Small)
->iconPosition(IconPosition::After),
];
}
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Select::make('bankAccountIdFiltered')
->label('Account')
->hiddenLabel()
->allowHtml()
->options(fn () => $this->getBankAccountOptions(true, true))
->live()
->selectablePlaceholder(false)
->columnSpan(4),
])
->columns(14);
}
public function transactionForm(Form $form): Form
{
return $form
->schema([
Forms\Components\DatePicker::make('posted_at')
->label('Date')
->required()
->displayFormat('Y-m-d'),
Forms\Components\TextInput::make('description')
->label('Description'),
Forms\Components\Select::make('bank_account_id')
->label('Account')
->options(fn () => $this->getBankAccountOptions())
->live()
->searchable()
->required(),
Forms\Components\Select::make('type')
->label('Type')
->live()
->options([
TransactionType::Deposit->value => TransactionType::Deposit->getLabel(),
TransactionType::Withdrawal->value => TransactionType::Withdrawal->getLabel(),
])
->required()
->afterStateUpdated(static fn (Forms\Set $set, $state) => $set('account_id', static::getUncategorizedAccountByType(TransactionType::parse($state))?->id)),
Forms\Components\TextInput::make('amount')
->label('Amount')
->money(static fn (Forms\Get $get) => BankAccount::find($get('bank_account_id'))?->account?->currency_code ?? 'USD')
->required(),
Forms\Components\Select::make('account_id')
->label('Category')
->options(fn (Forms\Get $get) => $this->getChartAccountOptions(type: TransactionType::parse($get('type')), nominalAccountsOnly: true))
->searchable()
->preload()
->required(),
Forms\Components\Textarea::make('notes')
->label('Notes')
->autosize()
->rows(10)
->columnSpanFull(),
])
->columns();
}
public function journalTransactionForm(Form $form): Form
{
return $form
->schema([
Tabs::make('Tabs')
->contained(false)
->tabs([
$this->getJournalTransactionFormEditTab(),
$this->getJournalTransactionFormNotesTab(),
]),
])
->columns(1);
}
public function table(Table $table): Table
{
return $table
->query(static::getEloquentQuery())
->modifyQueryUsing(function (Builder $query) {
if ($this->bankAccountIdFiltered !== 'all') {
$query->where('bank_account_id', $this->bankAccountIdFiltered);
}
})
->columns([
Tables\Columns\TextColumn::make('posted_at')
->label('Date')
->sortable()
->formatStateUsing(static function ($state) {
$dateFormat = Localization::firstOrFail()->date_format->value ?? DateFormat::DEFAULT;
return Carbon::parse($state)->translatedFormat($dateFormat);
}),
Tables\Columns\TextColumn::make('description')
->limit(30)
->label('Description'),
Tables\Columns\TextColumn::make('bankAccount.account.name')
->label('Account'),
Tables\Columns\TextColumn::make('account.name')
->label('Category')
->state(static fn (Transaction $record) => $record->account->name ?? 'Journal Entry'),
Tables\Columns\TextColumn::make('amount')
->label('Amount')
->weight(static fn (Transaction $record) => $record->reviewed ? null : FontWeight::SemiBold)
->color(
static fn (Transaction $record) => match ($record->type) {
TransactionType::Deposit => Color::rgb('rgb(' . Color::Green[700] . ')'),
TransactionType::Journal => 'primary',
default => null,
}
)
->currency(static fn (Transaction $record) => $record->bankAccount->account->currency_code ?? 'USD', true)
->state(fn (Transaction $record) => $record->type === TransactionType::Journal ? $record->journalEntries->first()->amount : $record->amount),
])
->recordClasses(static fn (Transaction $record) => $record->reviewed ? 'bg-primary-300/10' : null)
->defaultSort('posted_at', 'desc')
->filters([
Tables\Filters\Filter::make('filters')
->columnSpanFull()
->form([
Grid::make()
->schema([
Select::make('account_id')
->label('Category')
->options(fn () => $this->getChartAccountOptions(nominalAccountsOnly: true))
->multiple()
->searchable(),
Select::make('reviewed')
->label('Status')
->native(false)
->options([
'1' => 'Reviewed',
'0' => 'Not Reviewed',
]),
Select::make('type')
->label('Type')
->options(TransactionType::class)
->multiple(),
])
->extraAttributes([
'class' => 'border-b border-gray-200 dark:border-white/10 pb-8',
]),
Grid::make()
->schema([
Select::make('posted_at_date_range')
->label('Posted Date')
->placeholder('Select a date range')
->options([
'all' => 'All Dates', // Handle this later
'custom' => 'Custom Date Range',
]),
DatePicker::make('posted_at_start_date')
->label('Posted From')
->displayFormat('Y-m-d')
->columnStart(1),
DatePicker::make('posted_at_end_date')
->label('Posted To')
->displayFormat('Y-m-d'),
TextInput::make('posted_at_combined_dates')
->hidden(),
])
->extraAttributes([
'class' => 'border-b border-gray-200 dark:border-white/10 pb-8',
]),
Grid::make()
->schema([
Select::make('updated_at_date_range')
->label('Last Modified Date')
->placeholder('Select a date range')
->options([
'all' => 'All Dates', // Handle this later
'custom' => 'Custom Date Range',
]),
DatePicker::make('updated_at_start_date')
->label('Last Modified From')
->displayFormat('Y-m-d')
->columnStart(1),
DatePicker::make('updated_at_end_date')
->label('Last Modified To')
->displayFormat('Y-m-d'),
TextInput::make('updated_at_combined_dates')
->label('Updated Date Range')
->hidden(),
]),
])->query(function (Builder $query, array $data): Builder {
if (filled($data['reviewed'])) {
$reviewedStatus = $data['reviewed'] === '1';
$query->where('reviewed', $reviewedStatus);
}
$query
->when($data['account_id'], fn (Builder $query, $accountIds) => $query->whereIn('account_id', $accountIds))
->when($data['type'], fn (Builder $query, $types) => $query->whereIn('type', $types))
->when($data['posted_at_start_date'], fn (Builder $query, $startDate) => $query->whereDate('posted_at', '>=', $startDate))
->when($data['posted_at_end_date'], fn (Builder $query, $endDate) => $query->whereDate('posted_at', '<=', $endDate))
->when($data['updated_at_start_date'], fn (Builder $query, $startDate) => $query->whereDate('updated_at', '>=', $startDate))
->when($data['updated_at_end_date'], fn (Builder $query, $endDate) => $query->whereDate('updated_at', '<=', $endDate));
return $query;
})
->indicateUsing(function (array $data): array {
$indicators = [];
$this->addIndicatorForSingleSelection($data, 'reviewed', $data['reviewed'] === '1' ? 'Reviewed' : 'Not Reviewed', $indicators);
$this->addMultipleSelectionIndicator($data, 'account_id', fn ($accountId) => Account::find($accountId)->name, 'account_id', $indicators);
$this->addMultipleSelectionIndicator($data, 'type', fn ($type) => TransactionType::parse($type)->getLabel(), 'type', $indicators);
$this->addIndicatorForDateRange($data, 'posted_at_start_date', 'posted_at_end_date', 'Posted', 'posted_at_combined_dates', $indicators);
$this->addIndicatorForDateRange($data, 'updated_at_start_date', 'updated_at_end_date', 'Last Modified', 'updated_at_combined_dates', $indicators);
return $indicators;
}),
], layout: Tables\Enums\FiltersLayout::Modal)
->deferFilters()
->filtersFormColumns(2)
->filtersTriggerAction(
fn (Tables\Actions\Action $action) => $action
->stickyModalHeader()
->stickyModalFooter()
->modalWidth(MaxWidth::ThreeExtraLarge)
->modalFooterActionsAlignment(Alignment::End)
->modalCancelAction(false)
->extraModalFooterActions(function (Table $table) use ($action) {
return [
$table->getFiltersApplyAction()
->close(),
Actions\StaticAction::make('cancel')
->label($action->getModalCancelActionLabel())
->button()
->close()
->color('gray'),
Tables\Actions\Action::make('resetFilters')
->label(__('Clear All'))
->color('primary')
->link()
->extraAttributes([
'class' => 'me-auto',
])
->action('resetTableFiltersForm'),
];
})
)
->actions([
Tables\Actions\Action::make('markAsReviewed')
->label('Mark as Reviewed')
->view('filament.company.components.tables.actions.mark-as-reviewed')
->icon(static fn (Transaction $record) => $record->reviewed ? 'heroicon-s-check-circle' : 'heroicon-o-check-circle')
->color(static fn (Transaction $record, Tables\Actions\Action $action) => match (static::determineTransactionState($record, $action)) {
'reviewed' => 'primary',
'unreviewed' => Color::rgb('rgb(' . Color::Gray[600] . ')'),
'uncategorized' => 'gray',
})
->tooltip(static fn (Transaction $record, Tables\Actions\Action $action) => match (static::determineTransactionState($record, $action)) {
'reviewed' => 'Reviewed',
'unreviewed' => 'Mark as Reviewed',
'uncategorized' => 'Categorize first to mark as reviewed',
})
->disabled(fn (Transaction $record): bool => $record->isUncategorized())
->action(fn (Transaction $record) => $record->update(['reviewed' => ! $record->reviewed])),
Tables\Actions\ActionGroup::make([
Tables\Actions\EditAction::make('updateTransaction')
->label('Edit Transaction')
->modalHeading('Edit Transaction')
->modalWidth(MaxWidth::ThreeExtraLarge)
->form(fn (Form $form) => $this->transactionForm($form))
->hidden(static fn (Transaction $record) => $record->type === TransactionType::Journal),
Tables\Actions\EditAction::make('updateJournalTransaction')
->label('Edit Journal Transaction')
->modalHeading('Journal Entry')
->modalWidth(MaxWidth::Screen)
->form(fn (Form $form) => $this->journalTransactionForm($form))
->afterFormFilled(function (Transaction $record) {
$debitAmounts = $record->journalEntries->where('type', JournalEntryType::Debit)->sum('amount');
$creditAmounts = $record->journalEntries->where('type', JournalEntryType::Credit)->sum('amount');
$this->setDebitAmount($debitAmounts);
$this->setCreditAmount($creditAmounts);
})
->hidden(static fn (Transaction $record) => $record->type !== TransactionType::Journal),
Tables\Actions\DeleteAction::make(),
Tables\Actions\ReplicateAction::make()
->excludeAttributes(['created_by', 'updated_by', 'created_at', 'updated_at'])
->modal(false)
->beforeReplicaSaved(static function (Transaction $replica) {
$replica->description = '(Copy of) ' . $replica->description;
}),
])
->dropdownPlacement('bottom-start')
->dropdownWidth('max-w-fit'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
protected function buildTransactionAction(string $name, string $label, TransactionType $type): Actions\CreateAction
{
return Actions\CreateAction::make($name)
->label($label)
->modalWidth(MaxWidth::ThreeExtraLarge)
->model(static::getModel())
->fillForm(fn (): array => $this->getFormDefaultsForType($type))
->form(fn (Form $form) => $this->transactionForm($form))
->button()
->outlined();
}
protected function getFormDefaultsForType(TransactionType $type): array
{
$commonDefaults = [
'posted_at' => now()->format('Y-m-d'),
];
return match ($type) {
TransactionType::Deposit, TransactionType::Withdrawal => array_merge($commonDefaults, $this->transactionDefaults($type)),
TransactionType::Journal => array_merge($commonDefaults, $this->journalEntryDefaults()),
};
}
protected function journalEntryDefaults(): array
{
return [
'journalEntries' => [
$this->defaultEntry(JournalEntryType::Debit),
$this->defaultEntry(JournalEntryType::Credit),
],
];
}
protected function defaultEntry(JournalEntryType $journalEntryType): array
{
return [
'type' => $journalEntryType,
'account_id' => static::getUncategorizedAccountByType($journalEntryType->isDebit() ? TransactionType::Withdrawal : TransactionType::Deposit)?->id,
'amount' => '0.00',
];
}
protected function transactionDefaults(TransactionType $type): array
{
return [
'type' => $type,
'bank_account_id' => BankAccount::where('enabled', true)->first()?->id,
'amount' => '0.00',
'account_id' => static::getUncategorizedAccountByType($type)?->id,
];
}
protected static function getUncategorizedAccountByType(TransactionType $type): ?Account
{
[$category, $accountName] = match ($type) {
TransactionType::Deposit => [AccountCategory::Revenue, 'Uncategorized Income'],
TransactionType::Withdrawal => [AccountCategory::Expense, 'Uncategorized Expense'],
default => [null, null],
};
return Account::where('category', $category)
->where('name', $accountName)
->first();
}
protected function getJournalTransactionFormEditTab(): Tab
{
return Tab::make('Edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->schema([
$this->getTransactionDetailsGrid(),
$this->getJournalEntriesTableRepeater(),
]);
}
protected function getJournalTransactionFormNotesTab(): Tab
{
return Tab::make('Notes')
->label('Notes')
->icon('heroicon-o-clipboard')
->id('notes')
->schema([
$this->getTransactionDetailsGrid(),
Textarea::make('notes')
->label('Notes')
->rows(10)
->autosize(),
]);
}
protected function getTransactionDetailsGrid(): Grid
{
return Grid::make(8)
->schema([
DatePicker::make('posted_at')
->label('Date')
->softRequired()
->displayFormat('Y-m-d'),
TextInput::make('description')
->label('Description')
->columnSpan(2),
]);
}
protected function getJournalEntriesTableRepeater(): JournalEntryRepeater
{
return JournalEntryRepeater::make('journalEntries')
->relationship('journalEntries')
->hiddenLabel()
->columns(4)
->headers($this->getJournalEntriesTableRepeaterHeaders())
->schema($this->getJournalEntriesTableRepeaterSchema())
->streamlined()
->deletable(fn (JournalEntryRepeater $repeater) => $repeater->getItemsCount() > 2)
->minItems(2)
->defaultItems(2)
->addable(false)
->footerItem(fn (): View => $this->getJournalTransactionModalFooter())
->extraActions([
$this->buildAddJournalEntryAction(JournalEntryType::Debit),
$this->buildAddJournalEntryAction(JournalEntryType::Credit),
]);
}
protected function getJournalEntriesTableRepeaterHeaders(): array
{
return [
Header::make('type')
->width('150px')
->label('Type'),
Header::make('description')
->width('320px')
->label('Description'),
Header::make('account_id')
->width('320px')
->label('Account'),
Header::make('amount')
->width('192px')
->label('Amount'),
];
}
protected function getJournalEntriesTableRepeaterSchema(): array
{
return [
Select::make('type')
->label('Type')
->options(JournalEntryType::class)
->live()
->afterStateUpdated(function (Get $get, Set $set, ?string $state, ?string $old) {
$this->adjustJournalEntryAmountsForTypeChange(JournalEntryType::parse($state), JournalEntryType::parse($old), $get('amount'));
})
->softRequired(),
TextInput::make('description')
->label('Description'),
Select::make('account_id')
->label('Account')
->options(fn (): array => $this->getChartAccountOptions())
->live()
->softRequired()
->searchable(),
TextInput::make('amount')
->label('Amount')
->live()
->mask(RawJs::make('$money($input)'))
->afterStateUpdated(function (Get $get, Set $set, ?string $state, ?string $old) {
$this->updateJournalEntryAmount(JournalEntryType::parse($get('type')), $state, $old);
})
->softRequired(),
];
}
protected function buildAddJournalEntryAction(JournalEntryType $type): FormAction
{
$typeLabel = $type->getLabel();
return FormAction::make("add{$typeLabel}Entry")
->label("Add {$typeLabel} Entry")
->button()
->outlined()
->color($type->isDebit() ? 'primary' : 'gray')
->iconSize(IconSize::Small)
->iconPosition(IconPosition::Before)
->action(function (JournalEntryRepeater $component) use ($type) {
$state = $component->getState();
$newUuid = (string) Str::uuid();
$state[$newUuid] = $this->defaultEntry($type);
$component->state($state);
});
}
public function getJournalTransactionModalFooter(): View
{
return view(
'filament.company.components.actions.journal-entry-footer',
[
'debitAmount' => $this->getFormattedDebitAmount(),
'creditAmount' => $this->getFormattedCreditAmount(),
'difference' => $this->getFormattedBalanceDifference(),
'isJournalBalanced' => $this->isJournalEntryBalanced(),
],
);
}
protected function addIndicatorForSingleSelection($data, $key, $label, &$indicators): void
{
if (filled($data[$key])) {
$indicators[] = Tables\Filters\Indicator::make($label)
->removeField($key);
}
}
protected function addMultipleSelectionIndicator($data, $key, callable $labelRetriever, $field, &$indicators): void
{
if (filled($data[$key])) {
$labels = collect($data[$key])->map($labelRetriever);
$additionalCount = $labels->count() - 1;
$indicatorLabel = $additionalCount > 0 ? "{$labels->first()} + {$additionalCount}" : $labels->first();
$indicators[] = Tables\Filters\Indicator::make($indicatorLabel)
->removeField($field);
}
}
protected function addIndicatorForDateRange($data, $startKey, $endKey, $labelPrefix, $combinedFieldKey, &$indicators): void
{
$formattedStartDate = filled($data[$startKey]) ? Carbon::parse($data[$startKey])->toFormattedDateString() : null;
$formattedEndDate = filled($data[$endKey]) ? Carbon::parse($data[$endKey])->toFormattedDateString() : null;
if ($formattedStartDate && $formattedEndDate) {
$indicators[] = Tables\Filters\Indicator::make("{$labelPrefix}: {$formattedStartDate} - {$formattedEndDate}")
->removeField($combinedFieldKey); // Associate with the hidden combined_dates field for removal
} else {
if ($formattedStartDate) {
$indicators[] = Tables\Filters\Indicator::make("{$labelPrefix} After: {$formattedStartDate}")
->removeField($startKey);
}
if ($formattedEndDate) {
$indicators[] = Tables\Filters\Indicator::make("{$labelPrefix} Before: {$formattedEndDate}")
->removeField($endKey);
}
}
}
protected static function determineTransactionState(Transaction $record, Tables\Actions\Action $action): string
{
if ($record->reviewed) {
return 'reviewed';
}
if ($record->reviewed === false && $action->isEnabled()) {
return 'unreviewed';
}
return 'uncategorized';
}
protected function getChartAccountOptions(?TransactionType $type = null, bool $nominalAccountsOnly = false): array
{
$excludedCategory = match ($type) {
TransactionType::Deposit => AccountCategory::Expense,
TransactionType::Withdrawal => AccountCategory::Revenue,
default => null,
};
return Account::query()
->when($nominalAccountsOnly, fn (Builder $query) => $query->whereNull('accountable_type'))
->when($excludedCategory, fn (Builder $query) => $query->whereNot('category', $excludedCategory))
->get()
->groupBy(fn (Account $account) => $account->category->getPluralLabel())
->map(fn (Collection $accounts, string $category) => $accounts->pluck('name', 'id'))
->toArray();
}
protected function getBankAccountOptions(?bool $onlyWithTransactions = null, bool $isFilter = false): array
{
$onlyWithTransactions ??= false;
$options = $isFilter ? [
'' => ['all' => "All Accounts {$this->getBalanceForAllAccounts()}"],
] : [];
$bankAccountOptions = BankAccount::with('account.subtype')
->when($onlyWithTransactions, fn (Builder $query) => $query->has('transactions'))
->get()
->groupBy('account.subtype.name')
->mapWithKeys(function (Collection $bankAccounts, string $subtype) use ($isFilter) {
return [$subtype => $bankAccounts->mapWithKeys(function (BankAccount $bankAccount) use ($isFilter) {
$label = $bankAccount->account->name;
if ($isFilter) {
$balance = $this->getAccountBalance($bankAccount->account);
$label .= "{$balance}";
}
return [$bankAccount->id => $label];
})];
})
->toArray();
return array_merge($options, $bankAccountOptions);
}
protected function getAccountBalance(Account $account): ?string
{
return Accounting::getEndingBalance($account, $this->fiscalYearStartDate, $this->fiscalYearEndDate)?->formatted();
}
protected function getBalanceForAllAccounts(): string
{
return Accounting::getTotalBalanceForAllBankAccounts($this->fiscalYearStartDate, $this->fiscalYearEndDate)->formatted();
}
}