Browse Source

Merge pull request #69 from andrewdwallo/development-3.x

Development 3.x
3.x
Andrew Wallo 1 year ago
parent
commit
7c66fc2d37
No account linked to committer's email address
74 changed files with 3441 additions and 1793 deletions
  1. 24
    0
      README.md
  2. 0
    25
      app/Contracts/AccountHandler.php
  3. 0
    12
      app/Contracts/DocumentNumber.php
  4. 1
    0
      app/DTO/ReportDTO.php
  5. 1
    1
      app/Enums/Accounting/AccountCategory.php
  6. 11
    0
      app/Enums/Accounting/TransactionType.php
  7. 2
    17
      app/Facades/Accounting.php
  8. 14
    0
      app/Facades/Reporting.php
  9. 63
    0
      app/Factories/ReportDateFactory.php
  10. 1
    1
      app/Filament/Company/Clusters/Settings/Pages/Appearance.php
  11. 1
    1
      app/Filament/Company/Clusters/Settings/Pages/CompanyDefault.php
  12. 2
    2
      app/Filament/Company/Clusters/Settings/Pages/CompanyProfile.php
  13. 6
    4
      app/Filament/Company/Clusters/Settings/Pages/Invoice.php
  14. 1
    1
      app/Filament/Company/Clusters/Settings/Pages/Localization.php
  15. 143
    114
      app/Filament/Company/Pages/Accounting/Transactions.php
  16. 117
    0
      app/Filament/Company/Pages/Concerns/HasTableColumnToggleForm.php
  17. 0
    106
      app/Filament/Company/Pages/Concerns/HasToggleTableColumnForm.php
  18. 3
    1
      app/Filament/Company/Pages/Reports/AccountTransactions.php
  19. 75
    17
      app/Filament/Company/Pages/Reports/BaseReportPage.php
  20. 26
    12
      app/Filament/Company/Pages/Reports/TrialBalance.php
  21. 1
    1
      app/Filament/Components/PanelShiftDropdown.php
  22. 25
    7
      app/Filament/Forms/Components/DateRangeSelect.php
  23. 19
    0
      app/Filament/User/Clusters/Account.php
  24. 21
    0
      app/Filament/User/Clusters/Account/Pages/PersonalAccessTokens.php
  25. 21
    0
      app/Filament/User/Clusters/Account/Pages/Profile.php
  26. 15
    0
      app/Http/Responses/LoginResponse.php
  27. 10
    1
      app/Livewire/UpdateProfileInformation.php
  28. 13
    34
      app/Models/Setting/DocumentDefault.php
  29. 17
    0
      app/Models/User.php
  30. 18
    115
      app/Observers/TransactionObserver.php
  31. 2
    40
      app/Providers/AppServiceProvider.php
  32. 22
    18
      app/Providers/Filament/UserPanelProvider.php
  33. 6
    1
      app/Providers/FilamentCompaniesServiceProvider.php
  34. 8
    4
      app/Repositories/Accounting/JournalEntryRepository.php
  35. 3
    3
      app/Scopes/CurrentCompanyScope.php
  36. 72
    114
      app/Services/AccountService.php
  37. 30
    13
      app/Services/ExportService.php
  38. 73
    42
      app/Services/ReportService.php
  39. 137
    0
      app/Services/TransactionService.php
  40. 61
    0
      app/Testing/TestsReport.php
  41. 4
    1
      app/Transformers/TrialBalanceReportTransformer.php
  42. 0
    33
      app/Utilities/DocumentNumber.php
  43. 1
    1
      app/View/Models/InvoiceViewModel.php
  44. 3
    3
      composer.json
  45. 1491
    674
      composer.lock
  46. 68
    30
      database/factories/Accounting/TransactionFactory.php
  47. 40
    0
      database/factories/CompanyFactory.php
  48. 18
    10
      database/factories/Setting/CompanyProfileFactory.php
  49. 9
    36
      database/factories/UserFactory.php
  50. 4
    1
      database/seeders/DatabaseSeeder.php
  51. 21
    0
      database/seeders/TestDatabaseSeeder.php
  52. 125
    125
      package-lock.json
  53. 8
    8
      package.json
  54. 2
    2
      phpunit.xml
  55. 8
    0
      resources/css/filament/company/theme.css
  56. 1
    0
      resources/css/filament/user/tailwind.config.js
  57. 19
    8
      resources/js/TopNavigation.js
  58. 5
    1
      resources/views/components/company/reports/report-pdf.blade.php
  59. 14
    6
      resources/views/components/panel-shift-dropdown.blade.php
  60. 0
    1
      resources/views/filament/company/pages/accounting/transactions.blade.php
  61. 2
    2
      resources/views/filament/company/pages/reports/detailed-report.blade.php
  62. 2
    2
      resources/views/filament/company/pages/reports/income-statement.blade.php
  63. 45
    0
      resources/views/filament/company/pages/reports/trial-balance.blade.php
  64. 0
    0
      resources/views/vendor/filament-panel-switch/.gitkeep
  65. 0
    107
      resources/views/vendor/filament-panel-switch/panel-switch-menu.blade.php
  66. 3
    4
      resources/views/vendor/filament-panels/components/user-menu.blade.php
  67. 64
    0
      tests/Feature/CompanySetupAndBehaviorTest.php
  68. 4
    16
      tests/Feature/ExampleTest.php
  69. 145
    0
      tests/Feature/Reports/AccountBalancesReportTest.php
  70. 169
    0
      tests/Feature/Reports/TrialBalanceReportTest.php
  71. 24
    0
      tests/Helpers/helpers.php
  72. 35
    0
      tests/Pest.php
  73. 39
    1
      tests/TestCase.php
  74. 3
    14
      tests/Unit/ExampleTest.php

+ 24
- 0
README.md View File

@@ -213,6 +213,30 @@ After integrating Plaid, you can connect your account on the "Connected Accounts
213 213
 php artisan queue:work --queue=transactions
214 214
 ```
215 215
 
216
+## Testing
217
+
218
+This project includes testing using [Pest](https://pestphp.com/). The current
219
+test suite covers some reporting features and other core functionality. While it's not yet a fully comprehensive test
220
+suite, it provides a foundation for testing critical features.
221
+
222
+### Setting Up the Testing Environment
223
+
224
+#### Create a testing database
225
+
226
+Ensure that you create a separate testing database named `erpsaas_test` in your database management system (e.g.,
227
+MySQL).
228
+
229
+```bash
230
+CREATE DATABASE erpsaas_test;
231
+```
232
+
233
+### Running Tests
234
+
235
+The testing process automatically handles refreshing and seeding the test database with each test run, so no manual
236
+migration is required. For more information on how to write and run tests using
237
+Pest, refer to the official documentation: [Pest Documentation](https://pestphp.com/docs).
238
+
239
+
216 240
 ## Dependencies
217 241
 
218 242
 - [filamentphp/filament](https://github.com/filamentphp/filament) - A collection of beautiful full-stack components

+ 0
- 25
app/Contracts/AccountHandler.php View File

@@ -1,25 +0,0 @@
1
-<?php
2
-
3
-namespace App\Contracts;
4
-
5
-use App\Models\Accounting\Account;
6
-use App\ValueObjects\Money;
7
-
8
-interface AccountHandler
9
-{
10
-    public function getDebitBalance(Account $account, string $startDate, string $endDate): Money;
11
-
12
-    public function getCreditBalance(Account $account, string $startDate, string $endDate): Money;
13
-
14
-    public function getNetMovement(Account $account, string $startDate, string $endDate): Money;
15
-
16
-    public function getStartingBalance(Account $account, string $startDate): ?Money;
17
-
18
-    public function getEndingBalance(Account $account, string $startDate, string $endDate): ?Money;
19
-
20
-    public function getBalances(Account $account, string $startDate, string $endDate, array $fields): array;
21
-
22
-    public function getTotalBalanceForAllBankAccounts(string $startDate, string $endDate): Money;
23
-
24
-    public function getEarliestTransactionDate(): string;
25
-}

+ 0
- 12
app/Contracts/DocumentNumber.php View File

@@ -1,12 +0,0 @@
1
-<?php
2
-
3
-namespace App\Contracts;
4
-
5
-use Illuminate\Database\Eloquent\Model;
6
-
7
-interface DocumentNumber
8
-{
9
-    public function getNextNumber(?Model $model, ?string $type, int | string $number, string $prefix, int | string $digits, ?bool $padded = true): string;
10
-
11
-    public function incrementNumber(Model $model, string $type): void;
12
-}

+ 1
- 0
app/DTO/ReportDTO.php View File

@@ -11,5 +11,6 @@ class ReportDTO
11 11
         public array $categories,
12 12
         public ?AccountBalanceDTO $overallTotal = null,
13 13
         public array $fields = [],
14
+        public ?string $reportType = null,
14 15
     ) {}
15 16
 }

+ 1
- 1
app/Enums/Accounting/AccountCategory.php View File

@@ -75,7 +75,7 @@ enum AccountCategory: string implements HasLabel
75 75
     /**
76 76
      * Determines if the account is a real account.
77 77
      *
78
-     * In accounting, real accounts are permanent accounts that retain their balances across accounting periods.
78
+     * In accounting, assets, liabilities, and equity are real accounts which are permanent accounts that retain their balances across accounting periods.
79 79
      * They are not closed at the end of each accounting period.
80 80
      */
81 81
     public function isReal(): bool

+ 11
- 0
app/Enums/Accounting/TransactionType.php View File

@@ -12,6 +12,7 @@ enum TransactionType: string implements HasLabel
12 12
     case Deposit = 'deposit';
13 13
     case Withdrawal = 'withdrawal';
14 14
     case Journal = 'journal';
15
+    case Transfer = 'transfer';
15 16
 
16 17
     public function getLabel(): ?string
17 18
     {
@@ -32,4 +33,14 @@ enum TransactionType: string implements HasLabel
32 33
     {
33 34
         return $this === self::Journal;
34 35
     }
36
+
37
+    public function isTransfer(): bool
38
+    {
39
+        return $this === self::Transfer;
40
+    }
41
+
42
+    public function isStandard(): bool
43
+    {
44
+        return in_array($this, [self::Deposit, self::Withdrawal]);
45
+    }
35 46
 }

+ 2
- 17
app/Facades/Accounting.php View File

@@ -2,28 +2,13 @@
2 2
 
3 3
 namespace App\Facades;
4 4
 
5
-use App\Contracts\AccountHandler;
6
-use App\Models\Accounting\Account;
7
-use App\ValueObjects\Money;
5
+use App\Services\AccountService;
8 6
 use Illuminate\Support\Facades\Facade;
9 7
 
10
-/**
11
- * @method static Money getDebitBalance(Account $account, string $startDate, string $endDate)
12
- * @method static Money getCreditBalance(Account $account, string $startDate, string $endDate)
13
- * @method static Money getNetMovement(Account $account, string $startDate, string $endDate)
14
- * @method static Money|null getStartingBalance(Account $account, string $startDate)
15
- * @method static Money|null getEndingBalance(Account $account, string $startDate, string $endDate)
16
- * @method static array getBalances(Account $account, string $startDate, string $endDate)
17
- * @method static Money getTotalBalanceForAllBankAccounts(string $startDate, string $endDate)
18
- * @method static array getAccountCategoryOrder()
19
- * @method static string getEarliestTransactionDate()
20
- *
21
- * @see AccountHandler
22
- */
23 8
 class Accounting extends Facade
24 9
 {
25 10
     protected static function getFacadeAccessor(): string
26 11
     {
27
-        return AccountHandler::class;
12
+        return AccountService::class;
28 13
     }
29 14
 }

+ 14
- 0
app/Facades/Reporting.php View File

@@ -0,0 +1,14 @@
1
+<?php
2
+
3
+namespace App\Facades;
4
+
5
+use App\Services\ReportService;
6
+use Illuminate\Support\Facades\Facade;
7
+
8
+class Reporting extends Facade
9
+{
10
+    protected static function getFacadeAccessor(): string
11
+    {
12
+        return ReportService::class;
13
+    }
14
+}

+ 63
- 0
app/Factories/ReportDateFactory.php View File

@@ -0,0 +1,63 @@
1
+<?php
2
+
3
+namespace App\Factories;
4
+
5
+use App\Models\Company;
6
+use Illuminate\Support\Carbon;
7
+
8
+class ReportDateFactory
9
+{
10
+    public Carbon $fiscalYearStartDate;
11
+
12
+    public Carbon $fiscalYearEndDate;
13
+
14
+    public string $defaultDateRange;
15
+
16
+    public Carbon $defaultStartDate;
17
+
18
+    public Carbon $defaultEndDate;
19
+
20
+    public Carbon $earliestTransactionDate;
21
+
22
+    protected Company $company;
23
+
24
+    public function __construct(Company $company)
25
+    {
26
+        $this->company = $company;
27
+        $this->buildReportDates();
28
+    }
29
+
30
+    protected function buildReportDates(): void
31
+    {
32
+        $fiscalYearStartDate = Carbon::parse($this->company->locale->fiscalYearStartDate())->startOfDay();
33
+        $fiscalYearEndDate = Carbon::parse($this->company->locale->fiscalYearEndDate())->endOfDay();
34
+        $defaultDateRange = 'FY-' . now()->year;
35
+        $defaultStartDate = $fiscalYearStartDate->startOfDay();
36
+        $defaultEndDate = $fiscalYearEndDate->isFuture() ? now()->endOfDay() : $fiscalYearEndDate->endOfDay();
37
+
38
+        // Calculate the earliest transaction date based on the company's transactions
39
+        $earliestTransactionDate = $this->company->transactions()->min('posted_at')
40
+            ? Carbon::parse($this->company->transactions()->min('posted_at'))->startOfDay()
41
+            : $defaultStartDate;
42
+
43
+        // Assign values to properties
44
+        $this->fiscalYearStartDate = $fiscalYearStartDate;
45
+        $this->fiscalYearEndDate = $fiscalYearEndDate;
46
+        $this->defaultDateRange = $defaultDateRange;
47
+        $this->defaultStartDate = $defaultStartDate;
48
+        $this->defaultEndDate = $defaultEndDate;
49
+        $this->earliestTransactionDate = $earliestTransactionDate;
50
+    }
51
+
52
+    public function refresh(): self
53
+    {
54
+        $this->buildReportDates();
55
+
56
+        return $this;
57
+    }
58
+
59
+    public static function create(Company $company): self
60
+    {
61
+        return new static($company);
62
+    }
63
+}

+ 1
- 1
app/Filament/Company/Clusters/Settings/Pages/Appearance.php View File

@@ -49,7 +49,7 @@ class Appearance extends Page
49 49
         return translate(static::$title);
50 50
     }
51 51
 
52
-    public function getMaxContentWidth(): MaxWidth
52
+    public function getMaxContentWidth(): MaxWidth | string | null
53 53
     {
54 54
         return MaxWidth::ScreenTwoExtraLarge;
55 55
     }

+ 1
- 1
app/Filament/Company/Clusters/Settings/Pages/CompanyDefault.php View File

@@ -59,7 +59,7 @@ class CompanyDefault extends Page
59 59
         return translate(static::$title);
60 60
     }
61 61
 
62
-    public function getMaxContentWidth(): MaxWidth
62
+    public function getMaxContentWidth(): MaxWidth | string | null
63 63
     {
64 64
         return MaxWidth::ScreenTwoExtraLarge;
65 65
     }

+ 2
- 2
app/Filament/Company/Clusters/Settings/Pages/CompanyProfile.php View File

@@ -62,7 +62,7 @@ class CompanyProfile extends Page
62 62
         return translate(static::$title);
63 63
     }
64 64
 
65
-    public function getMaxContentWidth(): MaxWidth
65
+    public function getMaxContentWidth(): MaxWidth | string | null
66 66
     {
67 67
         return MaxWidth::ScreenTwoExtraLarge;
68 68
     }
@@ -183,7 +183,7 @@ class CompanyProfile extends Page
183 183
                     ->imageResizeMode('contain')
184 184
                     ->imageCropAspectRatio('1:1')
185 185
                     ->panelAspectRatio('1:1')
186
-                    ->panelLayout('compact')
186
+                    ->panelLayout('integrated')
187 187
                     ->removeUploadedFileButtonPosition('center bottom')
188 188
                     ->uploadButtonPosition('center bottom')
189 189
                     ->uploadProgressIndicatorPosition('center bottom')

+ 6
- 4
app/Filament/Company/Clusters/Settings/Pages/Invoice.php View File

@@ -63,7 +63,7 @@ class Invoice extends Page
63 63
         return translate(static::$title);
64 64
     }
65 65
 
66
-    public function getMaxContentWidth(): MaxWidth
66
+    public function getMaxContentWidth(): MaxWidth | string | null
67 67
     {
68 68
         return MaxWidth::ScreenTwoExtraLarge;
69 69
     }
@@ -137,13 +137,15 @@ class Invoice extends Page
137 137
                 TextInput::make('number_next')
138 138
                     ->softRequired()
139 139
                     ->localizeLabel()
140
-                    ->maxLength(static fn (Get $get) => $get('number_digits'))
141
-                    ->hint(static function (Get $get, $state) {
140
+                    ->mask(static function (Get $get) {
141
+                        return str_repeat('9', $get('number_digits'));
142
+                    })
143
+                    ->hint(function (Get $get, $state) {
142 144
                         $number_prefix = $get('number_prefix');
143 145
                         $number_digits = $get('number_digits');
144 146
                         $number_next = $state;
145 147
 
146
-                        return InvoiceModel::getNumberNext(true, true, $number_prefix, $number_digits, $number_next);
148
+                        return $this->record->getNumberNext(true, true, $number_prefix, $number_digits, $number_next);
147 149
                     }),
148 150
                 Select::make('payment_terms')
149 151
                     ->softRequired()

+ 1
- 1
app/Filament/Company/Clusters/Settings/Pages/Localization.php View File

@@ -60,7 +60,7 @@ class Localization extends Page
60 60
         return translate(static::$title);
61 61
     }
62 62
 
63
-    public function getMaxContentWidth(): MaxWidth
63
+    public function getMaxContentWidth(): MaxWidth | string | null
64 64
     {
65 65
         return MaxWidth::ScreenTwoExtraLarge;
66 66
     }

+ 143
- 114
app/Filament/Company/Pages/Accounting/Transactions.php View File

@@ -63,12 +63,8 @@ class Transactions extends Page implements HasTable
63 63
 
64 64
     protected static ?string $model = Transaction::class;
65 65
 
66
-    protected static ?string $navigationParentItem = 'Chart of Accounts';
67
-
68 66
     protected static ?string $navigationGroup = 'Accounting';
69 67
 
70
-    public ?string $bankAccountIdFiltered = 'all';
71
-
72 68
     public string $fiscalYearStartDate = '';
73 69
 
74 70
     public string $fiscalYearEndDate = '';
@@ -96,6 +92,15 @@ class Transactions extends Page implements HasTable
96 92
         return [
97 93
             $this->buildTransactionAction('addIncome', 'Add Income', TransactionType::Deposit),
98 94
             $this->buildTransactionAction('addExpense', 'Add Expense', TransactionType::Withdrawal),
95
+            Actions\CreateAction::make('addTransfer')
96
+                ->label('Add Transfer')
97
+                ->modalHeading('Add Transfer')
98
+                ->modalWidth(MaxWidth::ThreeExtraLarge)
99
+                ->model(static::getModel())
100
+                ->fillForm(fn (): array => $this->getFormDefaultsForType(TransactionType::Transfer))
101
+                ->form(fn (Form $form) => $this->transferForm($form))
102
+                ->button()
103
+                ->outlined(),
99 104
             Actions\ActionGroup::make([
100 105
                 Actions\CreateAction::make('addJournalTransaction')
101 106
                     ->label('Add Journal Transaction')
@@ -124,21 +129,57 @@ class Transactions extends Page implements HasTable
124 129
         ];
125 130
     }
126 131
 
127
-    public function form(Form $form): Form
132
+    public function transferForm(Form $form): Form
128 133
     {
129 134
         return $form
130 135
             ->schema([
131
-                Forms\Components\Select::make('bankAccountIdFiltered')
136
+                Forms\Components\DatePicker::make('posted_at')
137
+                    ->label('Date')
138
+                    ->required(),
139
+                Forms\Components\TextInput::make('description')
140
+                    ->label('Description'),
141
+                Forms\Components\Select::make('bank_account_id')
142
+                    ->label('From Account')
143
+                    ->options(fn (Get $get, ?Transaction $transaction) => $this->getBankAccountOptions(excludedAccountId: $get('account_id'), currentBankAccountId: $transaction?->bank_account_id))
132 144
                     ->live()
133
-                    ->allowHtml()
134
-                    ->hiddenLabel()
135
-                    ->columnSpan(2)
136
-                    ->label('Account')
137
-                    ->selectablePlaceholder(false)
138
-                    ->extraAttributes(['wire:key' => Str::random()])
139
-                    ->options(fn () => $this->getBankAccountOptions(true, true)),
145
+                    ->searchable()
146
+                    ->afterStateUpdated(function (Set $set, $state, $old, Get $get) {
147
+                        $amount = CurrencyConverter::convertAndSet(
148
+                            BankAccount::find($state)->account->currency_code,
149
+                            BankAccount::find($old)->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(),
150
+                            $get('amount')
151
+                        );
152
+
153
+                        if ($amount !== null) {
154
+                            $set('amount', $amount);
155
+                        }
156
+                    })
157
+                    ->required(),
158
+                Forms\Components\Select::make('type')
159
+                    ->label('Type')
160
+                    ->options([
161
+                        TransactionType::Transfer->value => TransactionType::Transfer->getLabel(),
162
+                    ])
163
+                    ->disabled()
164
+                    ->dehydrated()
165
+                    ->required(),
166
+                Forms\Components\TextInput::make('amount')
167
+                    ->label('Amount')
168
+                    ->money(static fn (Forms\Get $get) => BankAccount::find($get('bank_account_id'))?->account?->currency_code ?? CurrencyAccessor::getDefaultCurrency())
169
+                    ->required(),
170
+                Forms\Components\Select::make('account_id')
171
+                    ->label('To Account')
172
+                    ->live()
173
+                    ->options(fn (Get $get, ?Transaction $transaction) => $this->getBankAccountAccountOptions(excludedBankAccountId: $get('bank_account_id'), currentAccountId: $transaction?->account_id))
174
+                    ->searchable()
175
+                    ->required(),
176
+                Forms\Components\Textarea::make('notes')
177
+                    ->label('Notes')
178
+                    ->autosize()
179
+                    ->rows(10)
180
+                    ->columnSpanFull(),
140 181
             ])
141
-            ->columns(7);
182
+            ->columns();
142 183
     }
143 184
 
144 185
     public function transactionForm(Form $form): Form
@@ -222,16 +263,16 @@ class Transactions extends Page implements HasTable
222 263
                     'bankAccount.account',
223 264
                     'journalEntries.account',
224 265
                 ]);
225
-
226
-                if ($this->bankAccountIdFiltered !== 'all') {
227
-                    $query->where('bank_account_id', $this->bankAccountIdFiltered);
228
-                }
229 266
             })
230 267
             ->columns([
231 268
                 Tables\Columns\TextColumn::make('posted_at')
232 269
                     ->label('Date')
233 270
                     ->sortable()
234 271
                     ->defaultDateFormat(),
272
+                Tables\Columns\TextColumn::make('type')
273
+                    ->label('Type')
274
+                    ->sortable()
275
+                    ->toggleable(isToggledHiddenByDefault: true),
235 276
                 Tables\Columns\TextColumn::make('description')
236 277
                     ->label('Description')
237 278
                     ->limit(30)
@@ -241,6 +282,7 @@ class Transactions extends Page implements HasTable
241 282
                     ->toggleable(),
242 283
                 Tables\Columns\TextColumn::make('account.name')
243 284
                     ->label('Category')
285
+                    ->prefix(static fn (Transaction $transaction) => $transaction->type->isTransfer() ? 'Transfer to ' : null)
244 286
                     ->toggleable()
245 287
                     ->state(static fn (Transaction $transaction) => $transaction->account->name ?? 'Journal Entry'),
246 288
                 Tables\Columns\TextColumn::make('amount')
@@ -253,68 +295,51 @@ class Transactions extends Page implements HasTable
253 295
                             default => null,
254 296
                         }
255 297
                     )
298
+                    ->sortable()
256 299
                     ->currency(static fn (Transaction $transaction) => $transaction->bankAccount->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
257 300
             ])
258 301
             ->recordClasses(static fn (Transaction $transaction) => $transaction->reviewed ? 'bg-primary-300/10' : null)
259 302
             ->defaultSort('posted_at', 'desc')
260 303
             ->filters([
261
-                Tables\Filters\Filter::make('filters')
262
-                    ->columnSpanFull()
263
-                    ->form([
264
-                        Grid::make()
265
-                            ->schema([
266
-                                Select::make('account_id')
267
-                                    ->label('Category')
268
-                                    ->options(fn () => $this->getChartAccountOptions(nominalAccountsOnly: true))
269
-                                    ->multiple()
270
-                                    ->searchable(),
271
-                                Select::make('reviewed')
272
-                                    ->label('Status')
273
-                                    ->native(false)
274
-                                    ->options([
275
-                                        '1' => 'Reviewed',
276
-                                        '0' => 'Not Reviewed',
277
-                                    ]),
278
-                                Select::make('type')
279
-                                    ->label('Type')
280
-                                    ->options(TransactionType::class)
281
-                                    ->multiple(),
282
-                            ])
283
-                            ->extraAttributes([
284
-                                'class' => 'border-b border-gray-200 dark:border-white/10 pb-8',
285
-                            ]),
286
-                    ])->query(function (Builder $query, array $data): Builder {
287
-                        if (filled($data['reviewed'])) {
288
-                            $reviewedStatus = $data['reviewed'] === '1';
289
-                            $query->where('reviewed', $reviewedStatus);
290
-                        }
291
-
292
-                        $query
293
-                            ->when($data['account_id'], fn (Builder $query, $accountIds) => $query->whereIn('account_id', $accountIds))
294
-                            ->when($data['type'], fn (Builder $query, $types) => $query->whereIn('type', $types));
295
-
296
-                        return $query;
297
-                    })
298
-                    ->indicateUsing(function (array $data): array {
299
-                        $indicators = [];
300
-
301
-                        $this->addIndicatorForSingleSelection($data, 'reviewed', $data['reviewed'] === '1' ? 'Reviewed' : 'Not Reviewed', $indicators);
302
-                        $this->addMultipleSelectionIndicator($data, 'account_id', fn ($accountId) => Account::find($accountId)->name, 'account_id', $indicators);
303
-                        $this->addMultipleSelectionIndicator($data, 'type', fn ($type) => TransactionType::parse($type)->getLabel(), 'type', $indicators);
304
-
305
-                        return $indicators;
306
-                    }),
304
+                Tables\Filters\SelectFilter::make('bank_account_id')
305
+                    ->label('Account')
306
+                    ->searchable()
307
+                    ->options(fn () => $this->getBankAccountOptions(false)),
308
+                Tables\Filters\SelectFilter::make('account_id')
309
+                    ->label('Category')
310
+                    ->multiple()
311
+                    ->options(fn () => $this->getChartAccountOptions(nominalAccountsOnly: false)),
312
+                Tables\Filters\TernaryFilter::make('reviewed')
313
+                    ->label('Status')
314
+                    ->native(false)
315
+                    ->trueLabel('Reviewed')
316
+                    ->falseLabel('Not Reviewed'),
317
+                Tables\Filters\SelectFilter::make('type')
318
+                    ->label('Type')
319
+                    ->native(false)
320
+                    ->options(TransactionType::class),
307 321
                 $this->buildDateRangeFilter('posted_at', 'Posted', true),
308 322
                 $this->buildDateRangeFilter('updated_at', 'Last Modified'),
309 323
             ], layout: Tables\Enums\FiltersLayout::Modal)
324
+            ->filtersFormSchema(fn (array $filters): array => [
325
+                Grid::make()
326
+                    ->schema([
327
+                        $filters['bank_account_id'],
328
+                        $filters['account_id'],
329
+                        $filters['reviewed'],
330
+                        $filters['type'],
331
+                    ])
332
+                    ->columnSpanFull()
333
+                    ->extraAttributes(['class' => 'border-b border-gray-200 dark:border-white/10 pb-8']),
334
+                $filters['posted_at'],
335
+                $filters['updated_at'],
336
+            ])
310 337
             ->deferFilters()
311 338
             ->deferLoading()
312
-            ->filtersFormColumns(2)
339
+            ->filtersFormWidth(MaxWidth::ThreeExtraLarge)
313 340
             ->filtersTriggerAction(
314 341
                 fn (Tables\Actions\Action $action) => $action
315
-                    ->stickyModalHeader()
316
-                    ->stickyModalFooter()
317
-                    ->modalWidth(MaxWidth::ThreeExtraLarge)
342
+                    ->slideOver()
318 343
                     ->modalFooterActionsAlignment(Alignment::End)
319 344
                     ->modalCancelAction(false)
320 345
                     ->extraModalFooterActions(function (Table $table) use ($action) {
@@ -360,7 +385,13 @@ class Transactions extends Page implements HasTable
360 385
                         ->modalHeading('Edit Transaction')
361 386
                         ->modalWidth(MaxWidth::ThreeExtraLarge)
362 387
                         ->form(fn (Form $form) => $this->transactionForm($form))
363
-                        ->hidden(static fn (Transaction $transaction) => $transaction->type->isJournal()),
388
+                        ->visible(static fn (Transaction $transaction) => $transaction->type->isStandard()),
389
+                    Tables\Actions\EditAction::make('updateTransfer')
390
+                        ->label('Edit Transfer')
391
+                        ->modalHeading('Edit Transfer')
392
+                        ->modalWidth(MaxWidth::ThreeExtraLarge)
393
+                        ->form(fn (Form $form) => $this->transferForm($form))
394
+                        ->visible(static fn (Transaction $transaction) => $transaction->type->isTransfer()),
364 395
                     Tables\Actions\EditAction::make('updateJournalTransaction')
365 396
                         ->label('Edit Journal Transaction')
366 397
                         ->modalHeading('Journal Entry')
@@ -392,9 +423,7 @@ class Transactions extends Page implements HasTable
392 423
                                 ])->save();
393 424
                             });
394 425
                         }),
395
-                ])
396
-                    ->dropdownPlacement('bottom-start')
397
-                    ->dropdownWidth('max-w-fit'),
426
+                ]),
398 427
             ])
399 428
             ->bulkActions([
400 429
                 Tables\Actions\BulkActionGroup::make([
@@ -434,7 +463,7 @@ class Transactions extends Page implements HasTable
434 463
         ];
435 464
 
436 465
         return match ($type) {
437
-            TransactionType::Deposit, TransactionType::Withdrawal => array_merge($commonDefaults, $this->transactionDefaults($type)),
466
+            TransactionType::Deposit, TransactionType::Withdrawal, TransactionType::Transfer => array_merge($commonDefaults, $this->transactionDefaults($type)),
438 467
             TransactionType::Journal => array_merge($commonDefaults, $this->journalEntryDefaults()),
439 468
         };
440 469
     }
@@ -464,7 +493,7 @@ class Transactions extends Page implements HasTable
464 493
             'type' => $type,
465 494
             'bank_account_id' => BankAccount::where('enabled', true)->first()?->id,
466 495
             'amount' => '0.00',
467
-            'account_id' => static::getUncategorizedAccountByType($type)?->id,
496
+            'account_id' => ! $type->isTransfer() ? static::getUncategorizedAccountByType($type)->id : null,
468 497
         ];
469 498
     }
470 499
 
@@ -739,6 +768,46 @@ class Transactions extends Page implements HasTable
739 768
         return 'uncategorized';
740 769
     }
741 770
 
771
+    protected function getBankAccountOptions(?int $excludedAccountId = null, ?int $currentBankAccountId = null): array
772
+    {
773
+        return BankAccount::join('accounts', 'accounts.bank_account_id', '=', 'bank_accounts.id')
774
+            ->where('accounts.archived', false)
775
+            ->select(['bank_accounts.id', 'accounts.name', 'accounts.subtype_id'])
776
+            ->with(['account.subtype' => static function ($query) {
777
+                $query->select(['id', 'name']);
778
+            }])
779
+            ->when($excludedAccountId, function (Builder $query) use ($excludedAccountId) {
780
+                $query->whereNot('accounts.id', $excludedAccountId);
781
+            })
782
+            ->when($currentBankAccountId, function (Builder $query) use ($currentBankAccountId) {
783
+                // Ensure the current bank account is included even if archived
784
+                $query->orWhere('bank_accounts.id', $currentBankAccountId);
785
+            })
786
+            ->get()
787
+            ->groupBy('account.subtype.name')
788
+            ->map(fn (Collection $bankAccounts, string $subtype) => $bankAccounts->pluck('account.name', 'id'))
789
+            ->toArray();
790
+    }
791
+
792
+    protected function getBankAccountAccountOptions(?int $excludedBankAccountId = null, ?int $currentAccountId = null): array
793
+    {
794
+        return Account::query()
795
+            ->whereHas('bankAccount', function (Builder $query) use ($excludedBankAccountId) {
796
+                // Exclude the specific bank account if provided
797
+                if ($excludedBankAccountId) {
798
+                    $query->whereNot('id', $excludedBankAccountId);
799
+                }
800
+            })
801
+            ->where(function (Builder $query) use ($currentAccountId) {
802
+                $query->where('archived', false)
803
+                    ->orWhere('id', $currentAccountId);
804
+            })
805
+            ->get()
806
+            ->groupBy(fn (Account $account) => $account->category->getPluralLabel())
807
+            ->map(fn (Collection $accounts, string $category) => $accounts->pluck('name', 'id'))
808
+            ->toArray();
809
+    }
810
+
742 811
     protected function getChartAccountOptions(?TransactionType $type = null, ?bool $nominalAccountsOnly = null, ?int $currentAccountId = null): array
743 812
     {
744 813
         $nominalAccountsOnly ??= false;
@@ -762,46 +831,6 @@ class Transactions extends Page implements HasTable
762 831
             ->toArray();
763 832
     }
764 833
 
765
-    protected function getBankAccountOptions(?bool $onlyWithTransactions = null, ?bool $isFilter = null, ?int $currentBankAccountId = null): array
766
-    {
767
-        $isFilter ??= false;
768
-        $onlyWithTransactions ??= false;
769
-
770
-        $options = $isFilter ? [
771
-            '' => ['all' => "All Accounts <span class='float-right'>{$this->getBalanceForAllAccounts()}</span>"],
772
-        ] : [];
773
-
774
-        $bankAccountOptions = BankAccount::with('account.subtype')
775
-            ->whereHas('account', function (Builder $query) use ($isFilter, $currentBankAccountId) {
776
-                if ($isFilter === false) {
777
-                    $query->where('archived', false);
778
-                }
779
-
780
-                if ($currentBankAccountId) {
781
-                    $query->orWhereHas('bankAccount', function (Builder $query) use ($currentBankAccountId) {
782
-                        $query->where('id', $currentBankAccountId);
783
-                    });
784
-                }
785
-            })
786
-            ->when($onlyWithTransactions, fn (Builder $query) => $query->has('transactions'))
787
-            ->get()
788
-            ->groupBy('account.subtype.name')
789
-            ->mapWithKeys(function (Collection $bankAccounts, string $subtype) use ($isFilter) {
790
-                return [$subtype => $bankAccounts->mapWithKeys(static function (BankAccount $bankAccount) use ($isFilter) {
791
-                    $label = $bankAccount->account->name;
792
-                    if ($isFilter) {
793
-                        $balance = $bankAccount->account->ending_balance->convert()->formatWithCode(true);
794
-                        $label .= "<span class='float-right'>{$balance}</span>";
795
-                    }
796
-
797
-                    return [$bankAccount->id => $label];
798
-                })];
799
-            })
800
-            ->toArray();
801
-
802
-        return array_merge($options, $bankAccountOptions);
803
-    }
804
-
805 834
     protected function getBalanceForAllAccounts(): string
806 835
     {
807 836
         return Accounting::getTotalBalanceForAllBankAccounts($this->fiscalYearStartDate, $this->fiscalYearEndDate)->format();

+ 117
- 0
app/Filament/Company/Pages/Concerns/HasTableColumnToggleForm.php View File

@@ -0,0 +1,117 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Pages\Concerns;
4
+
5
+use App\Support\Column;
6
+use Filament\Actions\Action;
7
+use Filament\Forms\Components\Checkbox;
8
+use Filament\Forms\Form;
9
+use Filament\Support\Enums\ActionSize;
10
+use Filament\Support\Facades\FilamentIcon;
11
+use Illuminate\Support\Arr;
12
+
13
+trait HasTableColumnToggleForm
14
+{
15
+    public array $toggledTableColumns = [];
16
+
17
+    public function mountHasTableColumnToggleForm(): void
18
+    {
19
+        if (! count($this->toggledTableColumns ?? [])) {
20
+            $this->getTableColumnToggleForm()->fill(session()->get(
21
+                $this->getTableColumnToggleFormStateSessionKey(),
22
+                $this->getDefaultTableColumnToggleState()
23
+            ));
24
+        }
25
+    }
26
+
27
+    protected function getDefaultTableColumnToggleState(): array
28
+    {
29
+        $state = [];
30
+
31
+        foreach ($this->getTable() as $column) {
32
+            if (! $column->isToggleable()) {
33
+                continue;
34
+            }
35
+
36
+            data_set($state, $column->getName(), ! $column->isToggledHiddenByDefault());
37
+        }
38
+
39
+        return $state;
40
+    }
41
+
42
+    public function updatedToggledTableColumns(): void
43
+    {
44
+        session()->put([
45
+            $this->getTableColumnToggleFormStateSessionKey() => $this->toggledTableColumns,
46
+        ]);
47
+    }
48
+
49
+    public function getTableColumnToggleForm(): Form
50
+    {
51
+        if ((! $this->isCachingForms) && $this->hasCachedForm('toggleTableColumnForm')) {
52
+            return $this->getForm('toggleTableColumnForm');
53
+        }
54
+
55
+        return $this->makeForm()
56
+            ->schema($this->getTableColumnToggleFormSchema())
57
+            ->statePath('toggledTableColumns')
58
+            ->live();
59
+    }
60
+
61
+    protected function hasToggleableColumns(): bool
62
+    {
63
+        return ! empty($this->getTableColumnToggleFormSchema());
64
+    }
65
+
66
+    /**
67
+     * @return array<Checkbox>
68
+     */
69
+    protected function getTableColumnToggleFormSchema(): array
70
+    {
71
+        $schema = [];
72
+
73
+        foreach ($this->getTable() as $column) {
74
+            if (! $column->isToggleable()) {
75
+                continue;
76
+            }
77
+
78
+            $schema[] = Checkbox::make($column->getName())
79
+                ->label($column->getLabel());
80
+        }
81
+
82
+        return $schema;
83
+    }
84
+
85
+    public function isTableColumnToggledHidden(string $name): bool
86
+    {
87
+        return Arr::has($this->toggledTableColumns, $name) && ! data_get($this->toggledTableColumns, $name);
88
+    }
89
+
90
+    public function getTableColumnToggleFormStateSessionKey(): string
91
+    {
92
+        $table = class_basename($this::class);
93
+
94
+        return "tables.{$table}_toggled_columns";
95
+    }
96
+
97
+    public function getToggleColumnsTriggerAction(): Action
98
+    {
99
+        return Action::make('toggleColumns')
100
+            ->label(__('filament-tables::table.actions.toggle_columns.label'))
101
+            ->iconButton()
102
+            ->size(ActionSize::Large)
103
+            ->icon(FilamentIcon::resolve('tables::actions.toggle-columns') ?? 'heroicon-m-view-columns')
104
+            ->color('gray')
105
+            ->livewireClickHandlerEnabled(false);
106
+    }
107
+
108
+    protected function getToggledColumns(): array
109
+    {
110
+        return array_values(
111
+            array_filter(
112
+                $this->getTable(),
113
+                fn (Column $column) => ! $column->isToggleable() || ($this->toggledTableColumns[$column->getName()] ?? false)
114
+            )
115
+        );
116
+    }
117
+}

+ 0
- 106
app/Filament/Company/Pages/Concerns/HasToggleTableColumnForm.php View File

@@ -1,106 +0,0 @@
1
-<?php
2
-
3
-namespace App\Filament\Company\Pages\Concerns;
4
-
5
-use App\Support\Column;
6
-use Filament\Actions\Action;
7
-use Filament\Forms\Components\Checkbox;
8
-use Filament\Forms\Form;
9
-use Filament\Support\Enums\ActionSize;
10
-use Filament\Support\Facades\FilamentIcon;
11
-use Livewire\Attributes\Session;
12
-
13
-trait HasToggleTableColumnForm
14
-{
15
-    #[Session]
16
-    public array $toggledTableColumns = [];
17
-
18
-    public function mountHasToggleTableColumnForm(): void
19
-    {
20
-        $this->loadDefaultTableColumnToggleState();
21
-    }
22
-
23
-    protected function getHasToggleTableColumnFormForms(): array
24
-    {
25
-        return [
26
-            'toggleTableColumnForm' => $this->getToggleTableColumnForm(),
27
-        ];
28
-    }
29
-
30
-    public function getToggleTableColumnForm(): Form
31
-    {
32
-        return $this->toggleTableColumnForm($this->makeForm()
33
-            ->statePath('toggledTableColumns'));
34
-    }
35
-
36
-    public function toggleTableColumnForm(Form $form): Form
37
-    {
38
-        return $form;
39
-    }
40
-
41
-    protected function hasToggleableColumns(): bool
42
-    {
43
-        return ! empty($this->getTableColumnToggleFormSchema());
44
-    }
45
-
46
-    /**
47
-     * @return array<Checkbox>
48
-     */
49
-    protected function getTableColumnToggleFormSchema(): array
50
-    {
51
-        $schema = [];
52
-
53
-        foreach ($this->getTable() as $column) {
54
-            if ($column->isToggleable()) {
55
-                $schema[] = Checkbox::make($column->getName())
56
-                    ->label($column->getLabel());
57
-            }
58
-        }
59
-
60
-        return $schema;
61
-    }
62
-
63
-    public function toggleColumnsAction(): Action
64
-    {
65
-        return Action::make('toggleColumns')
66
-            ->label(__('filament-tables::table.actions.toggle_columns.label'))
67
-            ->iconButton()
68
-            ->size(ActionSize::Large)
69
-            ->icon(FilamentIcon::resolve('tables::actions.toggle-columns') ?? 'heroicon-m-view-columns')
70
-            ->color('gray');
71
-    }
72
-
73
-    protected function loadDefaultTableColumnToggleState(): void
74
-    {
75
-        $tableColumns = $this->getTable();
76
-
77
-        foreach ($tableColumns as $column) {
78
-            $columnName = $column->getName();
79
-
80
-            if (empty($this->toggledTableColumns)) {
81
-                if ($column->isToggleable()) {
82
-                    $this->toggledTableColumns[$columnName] = ! $column->isToggledHiddenByDefault();
83
-                } else {
84
-                    $this->toggledTableColumns[$columnName] = true;
85
-                }
86
-            }
87
-
88
-            // Handle cases where the toggle state needs to be reset
89
-            if (! $column->isToggleable()) {
90
-                $this->toggledTableColumns[$columnName] = true;
91
-            } elseif ($column->isToggleable() && $column->isToggledHiddenByDefault() && isset($this->toggledTableColumns[$columnName]) && $this->toggledTableColumns[$columnName]) {
92
-                $this->toggledTableColumns[$columnName] = false;
93
-            }
94
-        }
95
-    }
96
-
97
-    protected function getToggledColumns(): array
98
-    {
99
-        return array_values(
100
-            array_filter(
101
-                $this->getTable(),
102
-                fn (Column $column) => $this->toggledTableColumns[$column->getName()] ?? false,
103
-            )
104
-        );
105
-    }
106
-}

+ 3
- 1
app/Filament/Company/Pages/Reports/AccountTransactions.php View File

@@ -90,7 +90,9 @@ class AccountTransactions extends BaseReportPage
90 90
                 Cluster::make([
91 91
                     $this->getStartDateFormComponent(),
92 92
                     $this->getEndDateFormComponent(),
93
-                ])->label("\u{200B}"), // its too bad hiddenLabel removes spacing of the label
93
+                ])->extraFieldWrapperAttributes([
94
+                    'class' => 'report-hidden-label',
95
+                ]),
94 96
                 Actions::make([
95 97
                     Actions\Action::make('applyFilters')
96 98
                         ->label('Update Report')

+ 75
- 17
app/Filament/Company/Pages/Reports/BaseReportPage.php View File

@@ -5,20 +5,19 @@ namespace App\Filament\Company\Pages\Reports;
5 5
 use App\Contracts\ExportableReport;
6 6
 use App\DTO\ReportDTO;
7 7
 use App\Filament\Company\Pages\Concerns\HasDeferredFiltersForm;
8
-use App\Filament\Company\Pages\Concerns\HasToggleTableColumnForm;
8
+use App\Filament\Company\Pages\Concerns\HasTableColumnToggleForm;
9 9
 use App\Filament\Forms\Components\DateRangeSelect;
10 10
 use App\Models\Company;
11 11
 use App\Services\DateRangeService;
12 12
 use App\Support\Column;
13 13
 use Filament\Actions\Action;
14 14
 use Filament\Actions\ActionGroup;
15
-use Filament\Forms\Components\Component;
16 15
 use Filament\Forms\Components\DatePicker;
17
-use Filament\Forms\Form;
18 16
 use Filament\Forms\Set;
19 17
 use Filament\Pages\Page;
20 18
 use Filament\Support\Enums\IconPosition;
21 19
 use Filament\Support\Enums\IconSize;
20
+use Illuminate\Support\Arr;
22 21
 use Illuminate\Support\Carbon;
23 22
 use Livewire\Attributes\Computed;
24 23
 use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -26,7 +25,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
26 25
 abstract class BaseReportPage extends Page
27 26
 {
28 27
     use HasDeferredFiltersForm;
29
-    use HasToggleTableColumnForm;
28
+    use HasTableColumnToggleForm;
30 29
 
31 30
     public string $fiscalYearStartDate;
32 31
 
@@ -65,16 +64,63 @@ abstract class BaseReportPage extends Page
65 64
 
66 65
     protected function loadDefaultDateRange(): void
67 66
     {
68
-        $startDate = $this->getFilterState('startDate');
69
-        $endDate = $this->getFilterState('endDate');
67
+        $flatFields = $this->getFiltersForm()->getFlatFields();
70 68
 
71
-        if ($this->isValidDate($startDate) && $this->isValidDate($endDate)) {
72
-            $matchingDateRange = app(DateRangeService::class)->getMatchingDateRangeOption(Carbon::parse($startDate), Carbon::parse($endDate));
73
-            $this->setFilterState('dateRange', $matchingDateRange);
74
-        } else {
69
+        $dateRangeField = Arr::first($flatFields, static fn ($field) => $field instanceof DateRangeSelect);
70
+
71
+        if (! $dateRangeField) {
72
+            return;
73
+        }
74
+
75
+        $startDateField = $dateRangeField->getStartDateField();
76
+        $endDateField = $dateRangeField->getEndDateField();
77
+
78
+        $startDate = $startDateField ? $this->getFilterState($startDateField) : null;
79
+        $endDate = $endDateField ? $this->getFilterState($endDateField) : null;
80
+
81
+        $startDateCarbon = $this->isValidDate($startDate) ? Carbon::parse($startDate) : null;
82
+        $endDateCarbon = $this->isValidDate($endDate) ? Carbon::parse($endDate) : null;
83
+
84
+        if ($startDateCarbon && $endDateCarbon) {
85
+            $this->setMatchingDateRange($startDateCarbon, $endDateCarbon);
86
+
87
+            return;
88
+        }
89
+
90
+        if ($endDateCarbon && ! $startDateField) {
91
+            $this->setAsOfDateRange($endDateCarbon);
92
+
93
+            return;
94
+        }
95
+
96
+        if ($endDateField && ! $startDateField) {
75 97
             $this->setFilterState('dateRange', $this->getDefaultDateRange());
76
-            $this->setDateRange(Carbon::parse($this->fiscalYearStartDate), Carbon::parse($this->fiscalYearEndDate));
98
+            $defaultEndDate = Carbon::parse($this->fiscalYearEndDate);
99
+            $this->setFilterState($endDateField, $defaultEndDate->isFuture() ? now()->endOfDay()->toDateTimeString() : $defaultEndDate->endOfDay()->toDateTimeString());
100
+
101
+            return;
77 102
         }
103
+
104
+        if ($startDateField && $endDateField) {
105
+            $this->setFilterState('dateRange', $this->getDefaultDateRange());
106
+            $defaultStartDate = Carbon::parse($this->fiscalYearStartDate);
107
+            $defaultEndDate = Carbon::parse($this->fiscalYearEndDate);
108
+            $this->setDateRange($defaultStartDate, $defaultEndDate);
109
+        }
110
+    }
111
+
112
+    protected function setMatchingDateRange($startDate, $endDate): void
113
+    {
114
+        $matchingDateRange = app(DateRangeService::class)->getMatchingDateRangeOption($startDate, $endDate);
115
+        $this->setFilterState('dateRange', $matchingDateRange);
116
+    }
117
+
118
+    protected function setAsOfDateRange($endDate): void
119
+    {
120
+        $fiscalYearStart = Carbon::parse($this->fiscalYearStartDate);
121
+        $asOfStartDate = $endDate->copy()->setMonth($fiscalYearStart->month)->setDay($fiscalYearStart->day);
122
+
123
+        $this->setMatchingDateRange($asOfStartDate, $endDate);
78 124
     }
79 125
 
80 126
     public function loadReportData(): void
@@ -118,10 +164,9 @@ abstract class BaseReportPage extends Page
118 164
         return Carbon::parse($this->getFilterState('endDate'))->endOfDay()->toDateTimeString();
119 165
     }
120 166
 
121
-    public function toggleTableColumnForm(Form $form): Form
167
+    public function getFormattedAsOfDate(): string
122 168
     {
123
-        return $form
124
-            ->schema($this->getTableColumnToggleFormSchema());
169
+        return Carbon::parse($this->getFilterState('asOfDate'))->endOfDay()->toDateTimeString();
125 170
     }
126 171
 
127 172
     protected function getHeaderActions(): array
@@ -146,7 +191,7 @@ abstract class BaseReportPage extends Page
146 191
         ];
147 192
     }
148 193
 
149
-    protected function getDateRangeFormComponent(): Component
194
+    protected function getDateRangeFormComponent(): DateRangeSelect
150 195
     {
151 196
         return DateRangeSelect::make('dateRange')
152 197
             ->label('Date Range')
@@ -155,7 +200,7 @@ abstract class BaseReportPage extends Page
155 200
             ->endDateField('endDate');
156 201
     }
157 202
 
158
-    protected function getStartDateFormComponent(): Component
203
+    protected function getStartDateFormComponent(): DatePicker
159 204
     {
160 205
         return DatePicker::make('startDate')
161 206
             ->label('Start Date')
@@ -165,7 +210,7 @@ abstract class BaseReportPage extends Page
165 210
             });
166 211
     }
167 212
 
168
-    protected function getEndDateFormComponent(): Component
213
+    protected function getEndDateFormComponent(): DatePicker
169 214
     {
170 215
         return DatePicker::make('endDate')
171 216
             ->label('End Date')
@@ -174,4 +219,17 @@ abstract class BaseReportPage extends Page
174 219
                 $set('dateRange', 'Custom');
175 220
             });
176 221
     }
222
+
223
+    protected function getAsOfDateFormComponent(): DatePicker
224
+    {
225
+        return DatePicker::make('asOfDate')
226
+            ->label('As of Date')
227
+            ->live()
228
+            ->afterStateUpdated(static function (Set $set) {
229
+                $set('dateRange', 'Custom');
230
+            })
231
+            ->extraFieldWrapperAttributes([
232
+                'class' => 'report-hidden-label',
233
+            ]);
234
+    }
177 235
 }

+ 26
- 12
app/Filament/Company/Pages/Reports/TrialBalance.php View File

@@ -4,18 +4,19 @@ namespace App\Filament\Company\Pages\Reports;
4 4
 
5 5
 use App\Contracts\ExportableReport;
6 6
 use App\DTO\ReportDTO;
7
+use App\Filament\Forms\Components\DateRangeSelect;
7 8
 use App\Services\ExportService;
8 9
 use App\Services\ReportService;
9 10
 use App\Support\Column;
10 11
 use App\Transformers\TrialBalanceReportTransformer;
12
+use Filament\Forms\Components\Select;
11 13
 use Filament\Forms\Form;
12 14
 use Filament\Support\Enums\Alignment;
13
-use Guava\FilamentClusters\Forms\Cluster;
14 15
 use Symfony\Component\HttpFoundation\StreamedResponse;
15 16
 
16 17
 class TrialBalance extends BaseReportPage
17 18
 {
18
-    protected static string $view = 'filament.company.pages.reports.detailed-report';
19
+    protected static string $view = 'filament.company.pages.reports.trial-balance';
19 20
 
20 21
     protected static ?string $slug = 'reports/trial-balance';
21 22
 
@@ -31,6 +32,13 @@ class TrialBalance extends BaseReportPage
31 32
         $this->exportService = $exportService;
32 33
     }
33 34
 
35
+    protected function initializeDefaultFilters(): void
36
+    {
37
+        if (empty($this->getFilterState('reportType'))) {
38
+            $this->setFilterState('reportType', 'standard');
39
+        }
40
+    }
41
+
34 42
     public function getTable(): array
35 43
     {
36 44
         return [
@@ -53,20 +61,26 @@ class TrialBalance extends BaseReportPage
53 61
     public function filtersForm(Form $form): Form
54 62
     {
55 63
         return $form
56
-            ->inlineLabel()
57
-            ->columns()
64
+            ->columns(4)
58 65
             ->schema([
59
-                $this->getDateRangeFormComponent(),
60
-                Cluster::make([
61
-                    $this->getStartDateFormComponent(),
62
-                    $this->getEndDateFormComponent(),
63
-                ])->hiddenLabel(),
66
+                Select::make('reportType')
67
+                    ->label('Report Type')
68
+                    ->options([
69
+                        'standard' => 'Standard',
70
+                        'postClosing' => 'Post-Closing',
71
+                    ])
72
+                    ->selectablePlaceholder(false),
73
+                DateRangeSelect::make('dateRange')
74
+                    ->label('As of')
75
+                    ->selectablePlaceholder(false)
76
+                    ->endDateField('asOfDate'),
77
+                $this->getAsOfDateFormComponent(),
64 78
             ]);
65 79
     }
66 80
 
67 81
     protected function buildReport(array $columns): ReportDTO
68 82
     {
69
-        return $this->reportService->buildTrialBalanceReport($this->getFormattedStartDate(), $this->getFormattedEndDate(), $columns);
83
+        return $this->reportService->buildTrialBalanceReport($this->getFilterState('reportType'), $this->getFormattedAsOfDate(), $columns);
70 84
     }
71 85
 
72 86
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport
@@ -76,11 +90,11 @@ class TrialBalance extends BaseReportPage
76 90
 
77 91
     public function exportCSV(): StreamedResponse
78 92
     {
79
-        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
93
+        return $this->exportService->exportToCsv($this->company, $this->report, endDate: $this->getFilterState('asOfDate'));
80 94
     }
81 95
 
82 96
     public function exportPDF(): StreamedResponse
83 97
     {
84
-        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
98
+        return $this->exportService->exportToPdf($this->company, $this->report, endDate: $this->getFilterState('asOfDate'));
85 99
     }
86 100
 }

+ 1
- 1
app/Filament/Components/PanelShiftDropdown.php View File

@@ -193,7 +193,7 @@ class PanelShiftDropdown implements Plugin
193 193
             $panels['main']['items'][] = [
194 194
                 'panelId' => $companySettingsId,
195 195
                 'label' => 'Company Settings',
196
-                'icon' => 'heroicon-m-building-office-2',
196
+                'icon' => 'heroicon-s-building-office-2',
197 197
             ];
198 198
 
199 199
             $panels[$companySettingsId] = [

+ 25
- 7
app/Filament/Forms/Components/DateRangeSelect.php View File

@@ -28,9 +28,7 @@ class DateRangeSelect extends Select
28 28
         $this->options(app(DateRangeService::class)->getDateRangeOptions())
29 29
             ->live()
30 30
             ->afterStateUpdated(function ($state, Set $set) {
31
-                if ($this->startDateField && $this->endDateField) {
32
-                    $this->updateDateRange($state, $set);
33
-                }
31
+                $this->updateDateRange($state, $set);
34 32
             });
35 33
     }
36 34
 
@@ -48,11 +46,26 @@ class DateRangeSelect extends Select
48 46
         return $this;
49 47
     }
50 48
 
49
+    public function getStartDateField(): ?string
50
+    {
51
+        return $this->startDateField;
52
+    }
53
+
54
+    public function getEndDateField(): ?string
55
+    {
56
+        return $this->endDateField;
57
+    }
58
+
51 59
     public function updateDateRange($state, Set $set): void
52 60
     {
53 61
         if ($state === null) {
54
-            $set($this->startDateField, null);
55
-            $set($this->endDateField, null);
62
+            if ($this->startDateField) {
63
+                $set($this->startDateField, null);
64
+            }
65
+
66
+            if ($this->endDateField) {
67
+                $set($this->endDateField, null);
68
+            }
56 69
 
57 70
             return;
58 71
         }
@@ -116,7 +129,12 @@ class DateRangeSelect extends Select
116 129
 
117 130
     public function setDateRange(Carbon $start, Carbon $end, Set $set): void
118 131
     {
119
-        $set($this->startDateField, $start->startOfDay()->toDateTimeString());
120
-        $set($this->endDateField, $end->isFuture() ? now()->endOfDay()->toDateTimeString() : $end->endOfDay()->toDateTimeString());
132
+        if ($this->startDateField) {
133
+            $set($this->startDateField, $start->startOfDay()->toDateTimeString());
134
+        }
135
+
136
+        if ($this->endDateField) {
137
+            $set($this->endDateField, $end->isFuture() ? now()->endOfDay()->toDateTimeString() : $end->endOfDay()->toDateTimeString());
138
+        }
121 139
     }
122 140
 }

+ 19
- 0
app/Filament/User/Clusters/Account.php View File

@@ -0,0 +1,19 @@
1
+<?php
2
+
3
+namespace App\Filament\User\Clusters;
4
+
5
+use Filament\Clusters\Cluster;
6
+
7
+class Account extends Cluster
8
+{
9
+    protected static ?string $navigationIcon = 'heroicon-s-user';
10
+
11
+    protected static ?string $navigationLabel = 'My Account';
12
+
13
+    protected static ?string $clusterBreadcrumb = 'My Account';
14
+
15
+    public static function getNavigationUrl(): string
16
+    {
17
+        return static::getUrl(panel: 'user');
18
+    }
19
+}

+ 21
- 0
app/Filament/User/Clusters/Account/Pages/PersonalAccessTokens.php View File

@@ -0,0 +1,21 @@
1
+<?php
2
+
3
+namespace App\Filament\User\Clusters\Account\Pages;
4
+
5
+use App\Filament\User\Clusters\Account;
6
+use Filament\Support\Enums\MaxWidth;
7
+use Wallo\FilamentCompanies\Pages\User\PersonalAccessTokens as BasePersonalAccessTokens;
8
+
9
+class PersonalAccessTokens extends BasePersonalAccessTokens
10
+{
11
+    protected static ?string $cluster = Account::class;
12
+
13
+    protected static bool $shouldRegisterNavigation = true;
14
+
15
+    protected static ?int $navigationSort = 20;
16
+
17
+    public function getMaxContentWidth(): MaxWidth | string | null
18
+    {
19
+        return MaxWidth::ScreenTwoExtraLarge;
20
+    }
21
+}

+ 21
- 0
app/Filament/User/Clusters/Account/Pages/Profile.php View File

@@ -0,0 +1,21 @@
1
+<?php
2
+
3
+namespace App\Filament\User\Clusters\Account\Pages;
4
+
5
+use App\Filament\User\Clusters\Account;
6
+use Filament\Support\Enums\MaxWidth;
7
+use Wallo\FilamentCompanies\Pages\User\Profile as BaseProfile;
8
+
9
+class Profile extends BaseProfile
10
+{
11
+    protected static ?string $cluster = Account::class;
12
+
13
+    protected static bool $shouldRegisterNavigation = true;
14
+
15
+    protected static ?int $navigationSort = 10;
16
+
17
+    public function getMaxContentWidth(): MaxWidth | string | null
18
+    {
19
+        return MaxWidth::ScreenTwoExtraLarge;
20
+    }
21
+}

+ 15
- 0
app/Http/Responses/LoginResponse.php View File

@@ -0,0 +1,15 @@
1
+<?php
2
+
3
+namespace App\Http\Responses;
4
+
5
+use Filament\Facades\Filament;
6
+use Illuminate\Http\RedirectResponse;
7
+use Livewire\Features\SupportRedirects\Redirector;
8
+
9
+class LoginResponse extends \Filament\Http\Responses\Auth\LoginResponse
10
+{
11
+    public function toResponse($request): RedirectResponse | Redirector
12
+    {
13
+        return redirect()->to(Filament::getDefaultPanel()->getUrl());
14
+    }
15
+}

+ 10
- 1
app/Livewire/UpdateProfileInformation.php View File

@@ -132,10 +132,19 @@ class UpdateProfileInformation extends Component implements HasForms
132 132
             ->schema([
133 133
                 Forms\Components\FileUpload::make('profile_photo_path')
134 134
                     ->label('Photo')
135
-                    ->avatar()
136 135
                     ->extraAttributes([
137 136
                         'style' => 'width: 6rem; height: 6rem;',
138 137
                     ])
138
+                    ->panelLayout('compact circle')
139
+                    ->removeUploadedFileButtonPosition('center bottom')
140
+                    ->uploadButtonPosition('center bottom')
141
+                    ->imageResizeMode('cover')
142
+                    ->imageResizeUpscale(false)
143
+                    ->imageResizeTargetHeight('500')
144
+                    ->imageResizeTargetWidth('500')
145
+                    ->imageCropAspectRatio('1:1')
146
+                    ->uploadProgressIndicatorPosition('center bottom')
147
+                    ->loadingIndicatorPosition('center bottom')
139 148
                     ->placeholder(static function () {
140 149
                         return new HtmlString('
141 150
                             <div style="display: inline-block; cursor: pointer;">

+ 13
- 34
app/Models/Setting/DocumentDefault.php View File

@@ -93,53 +93,32 @@ class DocumentDefault extends Model
93 93
         return array_combine(range(1, 20), range(1, 20));
94 94
     }
95 95
 
96
-    public static function getNumberNext(?bool $padded = null, ?bool $format = null, ?string $prefix = null, int | string | null $digits = null, int | string | null $next = null, ?string $type = null): string
96
+    public function getNumberNext(?bool $padded = null, ?bool $format = null, ?string $prefix = null, int | string | null $digits = null, int | string | null $next = null): string
97 97
     {
98
-        $initializeAttributes = new static;
98
+        [$number_prefix, $number_digits, $number_next] = $this->initializeAttributes($prefix, $digits, $next);
99 99
 
100
-        [$number_prefix, $number_digits, $number_next] = $initializeAttributes->initializeAttributes($prefix, $digits, $next, $type);
101
-
102
-        if ($format) {
103
-            return $number_prefix . static::getPaddedNumberNext($number_next, $number_digits);
104
-        }
105
-
106
-        if ($padded) {
107
-            return static::getPaddedNumberNext($number_next, $number_digits);
108
-        }
109
-
110
-        return $number_next;
100
+        return match (true) {
101
+            $format && $padded => $number_prefix . $this->getPaddedNumberNext($number_next, $number_digits),
102
+            $format => $number_prefix . $number_next,
103
+            $padded => $this->getPaddedNumberNext($number_next, $number_digits),
104
+            default => $number_next,
105
+        };
111 106
     }
112 107
 
113
-    public function initializeAttributes(?string $prefix, int | string | null $digits, int | string | null $next, ?string $type): array
108
+    public function initializeAttributes(?string $prefix, int | string | null $digits, int | string | null $next): array
114 109
     {
115
-        $number_prefix = $prefix ?? $this->getAttributeFromArray('number_prefix');
116
-        $number_digits = $digits ?? $this->getAttributeFromArray('number_digits');
117
-        $number_next = $next ?? $this->getAttributeFromArray('number_next');
118
-
119
-        if ($type) {
120
-            $attributes = static::getAttributesByType($type);
121
-
122
-            $number_prefix = $attributes['number_prefix'] ?? $number_prefix;
123
-            $number_digits = $attributes['number_digits'] ?? $number_digits;
124
-            $number_next = $attributes['number_next'] ?? $number_next;
125
-        }
110
+        $number_prefix = $prefix ?? $this->number_prefix;
111
+        $number_digits = $digits ?? $this->number_digits;
112
+        $number_next = $next ?? $this->number_next;
126 113
 
127 114
         return [$number_prefix, $number_digits, $number_next];
128 115
     }
129 116
 
130
-    public static function getAttributesByType(?string $type): array
131
-    {
132
-        $model = new static;
133
-        $attributes = $model->newQuery()->type($type)->first();
134
-
135
-        return $attributes ? $attributes->toArray() : [];
136
-    }
137
-
138 117
     /**
139 118
      * Get the next number with padding for dynamic display purposes.
140 119
      * Even if number_next is a string, it will be cast to an integer.
141 120
      */
142
-    public static function getPaddedNumberNext(int | string | null $number_next, int | string | null $number_digits): string
121
+    public function getPaddedNumberNext(int | string | null $number_next, int | string | null $number_digits): string
143 122
     {
144 123
         return str_pad($number_next, $number_digits, '0', STR_PAD_LEFT);
145 124
     }

+ 17
- 0
app/Models/User.php View File

@@ -96,4 +96,21 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasDefaul
96 96
     {
97 97
         return $this->hasMany(Department::class, 'manager_id');
98 98
     }
99
+
100
+    public function switchCompany(mixed $company): bool
101
+    {
102
+        if (! $this->belongsToCompany($company)) {
103
+            return false;
104
+        }
105
+
106
+        $this->forceFill([
107
+            'current_company_id' => $company->id,
108
+        ])->save();
109
+
110
+        $this->setRelation('currentCompany', $company);
111
+
112
+        session(['current_company_id' => $company->id]);
113
+
114
+        return true;
115
+    }
99 116
 }

+ 18
- 115
app/Observers/TransactionObserver.php View File

@@ -2,33 +2,31 @@
2 2
 
3 3
 namespace App\Observers;
4 4
 
5
-use App\Enums\Accounting\JournalEntryType;
6
-use App\Models\Accounting\Account;
7
-use App\Models\Accounting\JournalEntry;
8 5
 use App\Models\Accounting\Transaction;
9
-use App\Utilities\Currency\CurrencyAccessor;
10
-use App\Utilities\Currency\CurrencyConverter;
11
-use Illuminate\Support\Facades\DB;
6
+use App\Services\TransactionService;
12 7
 
13 8
 class TransactionObserver
14 9
 {
10
+    public function __construct(
11
+        protected TransactionService $transactionService,
12
+    ) {}
13
+
15 14
     /**
16
-     * Handle the Transaction "created" event.
15
+     * Handle the Transaction "saving" event.
17 16
      */
18
-    public function created(Transaction $transaction): void
17
+    public function saving(Transaction $transaction): void
19 18
     {
20
-        // Additional check to avoid duplication during replication
21
-        if ($transaction->journalEntries()->exists() || $transaction->type->isJournal() || str_starts_with($transaction->description, '(Copy of)')) {
22
-            return;
23
-        }
24
-
25
-        [$debitAccount, $creditAccount] = $this->determineAccounts($transaction);
26
-
27
-        if ($debitAccount === null || $creditAccount === null) {
28
-            return;
19
+        if ($transaction->type->isTransfer() && $transaction->description === null) {
20
+            $transaction->description = 'Account Transfer';
29 21
         }
22
+    }
30 23
 
31
-        $this->createJournalEntries($transaction, $debitAccount, $creditAccount);
24
+    /**
25
+     * Handle the Transaction "created" event.
26
+     */
27
+    public function created(Transaction $transaction): void
28
+    {
29
+        $this->transactionService->createJournalEntries($transaction);
32 30
     }
33 31
 
34 32
     /**
@@ -38,29 +36,7 @@ class TransactionObserver
38 36
     {
39 37
         $transaction->refresh(); // DO NOT REMOVE
40 38
 
41
-        if ($transaction->type->isJournal() || $this->hasRelevantChanges($transaction) === false) {
42
-            return;
43
-        }
44
-
45
-        $journalEntries = $transaction->journalEntries;
46
-
47
-        $debitEntry = $journalEntries->where('type', JournalEntryType::Debit)->first();
48
-        $creditEntry = $journalEntries->where('type', JournalEntryType::Credit)->first();
49
-
50
-        if ($debitEntry === null || $creditEntry === null) {
51
-            return;
52
-        }
53
-
54
-        [$debitAccount, $creditAccount] = $this->determineAccounts($transaction);
55
-
56
-        if ($debitAccount === null || $creditAccount === null) {
57
-            return;
58
-        }
59
-
60
-        $convertedTransactionAmount = $this->getConvertedTransactionAmount($transaction);
61
-
62
-        $this->updateJournalEntriesForTransaction($debitEntry, $debitAccount, $convertedTransactionAmount);
63
-        $this->updateJournalEntriesForTransaction($creditEntry, $creditAccount, $convertedTransactionAmount);
39
+        $this->transactionService->updateJournalEntries($transaction);
64 40
     }
65 41
 
66 42
     /**
@@ -68,79 +44,6 @@ class TransactionObserver
68 44
      */
69 45
     public function deleting(Transaction $transaction): void
70 46
     {
71
-        DB::transaction(static function () use ($transaction) {
72
-            $transaction->journalEntries()->each(fn (JournalEntry $entry) => $entry->delete());
73
-        });
74
-    }
75
-
76
-    private function determineAccounts(Transaction $transaction): array
77
-    {
78
-        $chartAccount = $transaction->account;
79
-        $bankAccount = $transaction->bankAccount?->account;
80
-
81
-        $debitAccount = $transaction->type->isWithdrawal() ? $chartAccount : $bankAccount;
82
-        $creditAccount = $transaction->type->isWithdrawal() ? $bankAccount : $chartAccount;
83
-
84
-        return [$debitAccount, $creditAccount];
85
-    }
86
-
87
-    private function createJournalEntries(Transaction $transaction, Account $debitAccount, Account $creditAccount): void
88
-    {
89
-        $convertedTransactionAmount = $this->getConvertedTransactionAmount($transaction);
90
-
91
-        $debitAccount->journalEntries()->create([
92
-            'company_id' => $transaction->company_id,
93
-            'transaction_id' => $transaction->id,
94
-            'type' => JournalEntryType::Debit,
95
-            'amount' => $convertedTransactionAmount,
96
-            'description' => $transaction->description,
97
-        ]);
98
-
99
-        $creditAccount->journalEntries()->create([
100
-            'company_id' => $transaction->company_id,
101
-            'transaction_id' => $transaction->id,
102
-            'type' => JournalEntryType::Credit,
103
-            'amount' => $convertedTransactionAmount,
104
-            'description' => $transaction->description,
105
-        ]);
106
-    }
107
-
108
-    private function getConvertedTransactionAmount(Transaction $transaction): string
109
-    {
110
-        $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
111
-        $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
112
-        $chartAccountCurrency = $transaction->account->currency_code;
113
-
114
-        if ($bankAccountCurrency !== $defaultCurrency) {
115
-            return $this->convertToDefaultCurrency($transaction->amount, $bankAccountCurrency, $defaultCurrency);
116
-        } elseif ($chartAccountCurrency !== $defaultCurrency) {
117
-            return $this->convertToDefaultCurrency($transaction->amount, $chartAccountCurrency, $defaultCurrency);
118
-        }
119
-
120
-        return $transaction->amount;
121
-    }
122
-
123
-    private function convertToDefaultCurrency(string $amount, string $fromCurrency, string $toCurrency): string
124
-    {
125
-        $amountInCents = CurrencyConverter::prepareForAccessor($amount, $fromCurrency);
126
-
127
-        $convertedAmountInCents = CurrencyConverter::convertBalance($amountInCents, $fromCurrency, $toCurrency);
128
-
129
-        return CurrencyConverter::prepareForMutator($convertedAmountInCents, $toCurrency);
130
-    }
131
-
132
-    private function hasRelevantChanges(Transaction $transaction): bool
133
-    {
134
-        return $transaction->wasChanged(['amount', 'account_id', 'bank_account_id', 'type']);
135
-    }
136
-
137
-    private function updateJournalEntriesForTransaction(JournalEntry $journalEntry, Account $account, string $convertedTransactionAmount): void
138
-    {
139
-        DB::transaction(static function () use ($journalEntry, $account, $convertedTransactionAmount) {
140
-            $journalEntry->update([
141
-                'account_id' => $account->id,
142
-                'amount' => $convertedTransactionAmount,
143
-            ]);
144
-        });
47
+        $this->transactionService->deleteJournalEntries($transaction);
145 48
     }
146 49
 }

+ 2
- 40
app/Providers/AppServiceProvider.php View File

@@ -2,10 +2,8 @@
2 2
 
3 3
 namespace App\Providers;
4 4
 
5
-use App\Contracts\AccountHandler;
6
-use App\Services\AccountService;
7 5
 use App\Services\DateRangeService;
8
-use BezhanSalleh\PanelSwitch\PanelSwitch;
6
+use Filament\Http\Responses\Auth\Contracts\LoginResponse;
9 7
 use Filament\Notifications\Livewire\Notifications;
10 8
 use Filament\Support\Assets\Js;
11 9
 use Filament\Support\Enums\Alignment;
@@ -14,19 +12,13 @@ use Illuminate\Support\ServiceProvider;
14 12
 
15 13
 class AppServiceProvider extends ServiceProvider
16 14
 {
17
-    /**
18
-     * All of the container bindings that should be registered.
19
-     */
20
-    public array $bindings = [
21
-        AccountHandler::class => AccountService::class,
22
-    ];
23
-
24 15
     /**
25 16
      * Register any application services.
26 17
      */
27 18
     public function register(): void
28 19
     {
29 20
         $this->app->singleton(DateRangeService::class);
21
+        $this->app->singleton(LoginResponse::class, \App\Http\Responses\LoginResponse::class);
30 22
     }
31 23
 
32 24
     /**
@@ -36,38 +28,8 @@ class AppServiceProvider extends ServiceProvider
36 28
     {
37 29
         Notifications::alignment(Alignment::Center);
38 30
 
39
-        $this->configurePanelSwitch();
40
-
41 31
         FilamentAsset::register([
42 32
             Js::make('TopNavigation', __DIR__ . '/../../resources/js/TopNavigation.js'),
43 33
         ]);
44 34
     }
45
-
46
-    /**
47
-     * Configure the panel switch.
48
-     */
49
-    protected function configurePanelSwitch(): void
50
-    {
51
-        PanelSwitch::configureUsing(function (PanelSwitch $panelSwitch) {
52
-            $panelSwitch
53
-                ->modalHeading('Switch Panel')
54
-                ->modalWidth('md')
55
-                ->slideOver()
56
-                ->excludes(['admin'])
57
-                ->iconSize(16)
58
-                ->icons(function () {
59
-                    if (auth()->user()?->belongsToCompany(auth()->user()?->currentCompany)) {
60
-                        return [
61
-                            'user' => 'heroicon-o-user',
62
-                            'company' => 'heroicon-o-building-office',
63
-                        ];
64
-                    }
65
-
66
-                    return [
67
-                        'user' => 'heroicon-o-user',
68
-                        'company' => 'icon-building-add',
69
-                    ];
70
-                });
71
-        });
72
-    }
73 35
 }

+ 22
- 18
app/Providers/Filament/UserPanelProvider.php View File

@@ -2,17 +2,18 @@
2 2
 
3 3
 namespace App\Providers\Filament;
4 4
 
5
+use App\Filament\Components\PanelShiftDropdown;
6
+use App\Filament\User\Clusters\Account;
5 7
 use App\Http\Middleware\Authenticate;
6 8
 use Exception;
7 9
 use Filament\Http\Middleware\DisableBladeIconComponents;
8 10
 use Filament\Http\Middleware\DispatchServingFilamentEvent;
9
-use Filament\Navigation\MenuItem;
11
+use Filament\Navigation\NavigationBuilder;
10 12
 use Filament\Navigation\NavigationItem;
11 13
 use Filament\Pages;
12 14
 use Filament\Panel;
13 15
 use Filament\PanelProvider;
14 16
 use Filament\Support\Colors\Color;
15
-use Filament\Widgets;
16 17
 use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
17 18
 use Illuminate\Cookie\Middleware\EncryptCookies;
18 19
 use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
@@ -33,33 +34,36 @@ class UserPanelProvider extends PanelProvider
33 34
         return $panel
34 35
             ->id('user')
35 36
             ->path('user')
36
-            ->userMenuItems([
37
-                'profile' => MenuItem::make()
38
-                    ->label('Profile')
39
-                    ->icon('heroicon-o-user-circle')
40
-                    ->url(static fn () => url(Profile::getUrl())),
41
-            ])
42
-            ->navigationItems([
43
-                NavigationItem::make('Personal Access Tokens')
44
-                    ->label(static fn (): string => __('filament-companies::default.navigation.links.tokens'))
45
-                    ->icon('heroicon-o-key')
46
-                    ->url(static fn () => url(PersonalAccessTokens::getUrl())),
47
-            ])
37
+            ->plugin(
38
+                PanelShiftDropdown::make()
39
+                    ->logoutItem()
40
+                    ->companySettings(false)
41
+                    ->navigation(function (NavigationBuilder $builder): NavigationBuilder {
42
+                        return $builder
43
+                            ->items([
44
+                                ...Account::getNavigationItems(),
45
+                                NavigationItem::make('company')
46
+                                    ->label('Company Dashboard')
47
+                                    ->icon('heroicon-s-building-office-2')
48
+                                    ->url(static fn (): string => Pages\Dashboard::getUrl(panel: 'company', tenant: auth()->user()->personalCompany())),
49
+                            ]);
50
+                    }),
51
+            )
48 52
             ->colors([
49 53
                 'primary' => Color::Indigo,
50 54
             ])
55
+            ->navigation(false)
51 56
             ->viteTheme('resources/css/filament/user/theme.css')
52 57
             ->discoverResources(in: app_path('Filament/User/Resources'), for: 'App\\Filament\\User\\Resources')
53 58
             ->discoverPages(in: app_path('Filament/User/Pages'), for: 'App\\Filament\\User\\Pages')
59
+            ->discoverClusters(in: app_path('Filament/User/Clusters'), for: 'App\\Filament\\User\\Clusters')
60
+            ->discoverWidgets(in: app_path('Filament/User/Widgets'), for: 'App\\Filament\\User\\Widgets')
54 61
             ->pages([
55
-                Pages\Dashboard::class,
56 62
                 Profile::class,
57 63
                 PersonalAccessTokens::class,
58 64
             ])
59
-            ->discoverWidgets(in: app_path('Filament/User/Widgets'), for: 'App\\Filament\\User\\Widgets')
60 65
             ->widgets([
61
-                Widgets\AccountWidget::class,
62
-                Widgets\FilamentInfoWidget::class,
66
+                //
63 67
             ])
64 68
             ->middleware([
65 69
                 EncryptCookies::class,

+ 6
- 1
app/Providers/FilamentCompaniesServiceProvider.php View File

@@ -28,6 +28,7 @@ use App\Filament\Company\Pages\Service\LiveCurrency;
28 28
 use App\Filament\Company\Resources\Banking\AccountResource;
29 29
 use App\Filament\Company\Resources\Core\DepartmentResource;
30 30
 use App\Filament\Components\PanelShiftDropdown;
31
+use App\Filament\User\Clusters\Account;
31 32
 use App\Http\Middleware\ConfigureCurrentCompany;
32 33
 use App\Livewire\UpdatePassword;
33 34
 use App\Livewire\UpdateProfileInformation;
@@ -102,7 +103,11 @@ class FilamentCompaniesServiceProvider extends PanelProvider
102 103
             ->plugin(
103 104
                 PanelShiftDropdown::make()
104 105
                     ->logoutItem()
105
-                    ->companySettings(),
106
+                    ->companySettings()
107
+                    ->navigation(function (NavigationBuilder $builder): NavigationBuilder {
108
+                        return $builder
109
+                            ->items(Account::getNavigationItems());
110
+                    }),
106 111
             )
107 112
             ->colors([
108 113
                 'primary' => Color::Indigo,

+ 8
- 4
app/Repositories/Accounting/JournalEntryRepository.php View File

@@ -4,6 +4,7 @@ namespace App\Repositories\Accounting;
4 4
 
5 5
 use App\Models\Accounting\Account;
6 6
 use Illuminate\Database\Eloquent\Builder;
7
+use Illuminate\Support\Carbon;
7 8
 
8 9
 class JournalEntryRepository
9 10
 {
@@ -11,13 +12,16 @@ class JournalEntryRepository
11 12
     {
12 13
         $query = $account->journalEntries()->where('type', $type);
13 14
 
15
+        $startDateCarbon = Carbon::parse($startDate)->startOfDay();
16
+        $endDateCarbon = Carbon::parse($endDate)->endOfDay();
17
+
14 18
         if ($startDate && $endDate) {
15
-            $query->whereHas('transaction', static function (Builder $query) use ($startDate, $endDate) {
16
-                $query->whereBetween('posted_at', [$startDate, $endDate]);
19
+            $query->whereHas('transaction', static function (Builder $query) use ($startDateCarbon, $endDateCarbon) {
20
+                $query->whereBetween('posted_at', [$startDateCarbon, $endDateCarbon]);
17 21
             });
18 22
         } elseif ($startDate) {
19
-            $query->whereHas('transaction', static function (Builder $query) use ($startDate) {
20
-                $query->where('posted_at', '<=', $startDate);
23
+            $query->whereHas('transaction', static function (Builder $query) use ($startDateCarbon) {
24
+                $query->where('posted_at', '<=', $startDateCarbon);
21 25
             });
22 26
         }
23 27
 

+ 3
- 3
app/Scopes/CurrentCompanyScope.php View File

@@ -16,12 +16,12 @@ class CurrentCompanyScope implements Scope
16 16
      */
17 17
     public function apply(Builder $builder, Model $model): void
18 18
     {
19
-        if (app()->runningInConsole()) {
19
+        $companyId = session('current_company_id');
20
+
21
+        if (! $companyId && app()->runningInConsole()) {
20 22
             return;
21 23
         }
22 24
 
23
-        $companyId = session('current_company_id');
24
-
25 25
         if (! $companyId && Auth::check() && Auth::user()->currentCompany) {
26 26
             $companyId = Auth::user()->currentCompany->id;
27 27
             session(['current_company_id' => $companyId]);

+ 72
- 114
app/Services/AccountService.php View File

@@ -2,10 +2,8 @@
2 2
 
3 3
 namespace App\Services;
4 4
 
5
-use App\Contracts\AccountHandler;
6 5
 use App\Enums\Accounting\AccountCategory;
7 6
 use App\Models\Accounting\Account;
8
-use App\Models\Accounting\JournalEntry;
9 7
 use App\Models\Accounting\Transaction;
10 8
 use App\Repositories\Accounting\JournalEntryRepository;
11 9
 use App\Utilities\Currency\CurrencyAccessor;
@@ -15,7 +13,7 @@ use Illuminate\Database\Eloquent\Builder;
15 13
 use Illuminate\Database\Query\JoinClause;
16 14
 use Illuminate\Support\Facades\DB;
17 15
 
18
-class AccountService implements AccountHandler
16
+class AccountService
19 17
 {
20 18
     public function __construct(
21 19
         protected JournalEntryRepository $journalEntryRepository
@@ -23,34 +21,94 @@ class AccountService implements AccountHandler
23 21
 
24 22
     public function getDebitBalance(Account $account, string $startDate, string $endDate): Money
25 23
     {
26
-        $amount = $this->journalEntryRepository->sumDebitAmounts($account, $startDate, $endDate);
24
+        $query = $this->getAccountBalances($startDate, $endDate, [$account->id])->first();
27 25
 
28
-        return new Money($amount, $account->currency_code);
26
+        return new Money($query->total_debit, $account->currency_code);
29 27
     }
30 28
 
31 29
     public function getCreditBalance(Account $account, string $startDate, string $endDate): Money
32 30
     {
33
-        $amount = $this->journalEntryRepository->sumCreditAmounts($account, $startDate, $endDate);
31
+        $query = $this->getAccountBalances($startDate, $endDate, [$account->id])->first();
34 32
 
35
-        return new Money($amount, $account->currency_code);
33
+        return new Money($query->total_credit, $account->currency_code);
36 34
     }
37 35
 
38 36
     public function getNetMovement(Account $account, string $startDate, string $endDate): Money
39 37
     {
40
-        $balances = $this->calculateBalances($account, $startDate, $endDate);
38
+        $query = $this->getAccountBalances($startDate, $endDate, [$account->id])->first();
41 39
 
42
-        return new Money($balances['net_movement'], $account->currency_code);
40
+        $netMovement = $this->calculateNetMovementByCategory(
41
+            $account->category,
42
+            $query->total_debit ?? 0,
43
+            $query->total_credit ?? 0
44
+        );
45
+
46
+        return new Money($netMovement, $account->currency_code);
43 47
     }
44 48
 
45 49
     public function getStartingBalance(Account $account, string $startDate, bool $override = false): ?Money
46 50
     {
47
-        if ($override === false && in_array($account->category, [AccountCategory::Expense, AccountCategory::Revenue], true)) {
51
+        if ($override === false && $account->category->isNominal()) {
48 52
             return null;
49 53
         }
50 54
 
51
-        $balances = $this->calculateStartingBalances($account, $startDate);
55
+        $query = $this->getAccountBalances($startDate, $startDate, [$account->id])->first();
56
+
57
+        return new Money($query->starting_balance ?? 0, $account->currency_code);
58
+    }
52 59
 
53
-        return new Money($balances['starting_balance'], $account->currency_code);
60
+    public function getEndingBalance(Account $account, string $startDate, string $endDate): ?Money
61
+    {
62
+        $query = $this->getAccountBalances($startDate, $endDate, [$account->id])->first();
63
+
64
+        $netMovement = $this->calculateNetMovementByCategory(
65
+            $account->category,
66
+            $query->total_debit ?? 0,
67
+            $query->total_credit ?? 0
68
+        );
69
+
70
+        if ($account->category->isNominal()) {
71
+            return new Money($netMovement, $account->currency_code);
72
+        }
73
+
74
+        $endingBalance = ($query->starting_balance ?? 0) + $netMovement;
75
+
76
+        return new Money($endingBalance, $account->currency_code);
77
+    }
78
+
79
+    private function calculateNetMovementByCategory(AccountCategory $category, int $debitBalance, int $creditBalance): int
80
+    {
81
+        if ($category->isNormalDebitBalance()) {
82
+            return $debitBalance - $creditBalance;
83
+        } else {
84
+            return $creditBalance - $debitBalance;
85
+        }
86
+    }
87
+
88
+    public function getBalances(Account $account, string $startDate, string $endDate): array
89
+    {
90
+        $query = $this->getAccountBalances($startDate, $endDate, [$account->id])->first();
91
+
92
+        $needStartingBalances = $account->category->isReal();
93
+
94
+        $netMovement = $this->calculateNetMovementByCategory(
95
+            $account->category,
96
+            $query->total_debit ?? 0,
97
+            $query->total_credit ?? 0
98
+        );
99
+
100
+        $balances = [
101
+            'debit_balance' => $query->total_debit,
102
+            'credit_balance' => $query->total_credit,
103
+            'net_movement' => $netMovement,
104
+            'starting_balance' => $needStartingBalances ? ($query->starting_balance ?? 0) : null,
105
+            'ending_balance' => $needStartingBalances
106
+                ? ($query->starting_balance ?? 0) + $netMovement
107
+                : $netMovement, // For nominal accounts, ending balance is just the net movement
108
+        ];
109
+
110
+        // Return balances, filtering out any null values
111
+        return array_filter($balances, static fn ($value) => $value !== null);
54 112
     }
55 113
 
56 114
     public function getTransactionDetailsSubquery(string $startDate, string $endDate): Closure
@@ -165,110 +223,10 @@ class AccountService implements AccountHandler
165 223
         return new Money($totalBalance, CurrencyAccessor::getDefaultCurrency());
166 224
     }
167 225
 
168
-    public function getEndingBalance(Account $account, string $startDate, string $endDate): ?Money
169
-    {
170
-        $calculatedBalances = $this->calculateBalances($account, $startDate, $endDate);
171
-        $startingBalances = $this->calculateStartingBalances($account, $startDate);
172
-
173
-        $netMovement = $calculatedBalances['net_movement'];
174
-
175
-        if (in_array($account->category, [AccountCategory::Expense, AccountCategory::Revenue], true)) {
176
-            return new Money($netMovement, $account->currency_code);
177
-        }
178
-
179
-        $endingBalance = $startingBalances['starting_balance'] + $netMovement;
180
-
181
-        return new Money($endingBalance, $account->currency_code);
182
-    }
183
-
184
-    public function getRetainedEarnings(string $startDate): Money
185
-    {
186
-        $revenue = JournalEntry::whereHas('account', static function ($query) {
187
-            $query->where('category', AccountCategory::Revenue);
188
-        })
189
-            ->where('type', 'credit')
190
-            ->whereHas('transaction', static function ($query) use ($startDate) {
191
-                $query->where('posted_at', '<', $startDate);
192
-            })
193
-            ->sum('amount');
194
-
195
-        $expense = JournalEntry::whereHas('account', static function ($query) {
196
-            $query->where('category', AccountCategory::Expense);
197
-        })
198
-            ->where('type', 'debit')
199
-            ->whereHas('transaction', static function ($query) use ($startDate) {
200
-                $query->where('posted_at', '<', $startDate);
201
-            })
202
-            ->sum('amount');
203
-
204
-        $retainedEarnings = $revenue - $expense;
205
-
206
-        return new Money($retainedEarnings, CurrencyAccessor::getDefaultCurrency());
207
-    }
208
-
209
-    private function calculateNetMovementByCategory(AccountCategory $category, int $debitBalance, int $creditBalance): int
210
-    {
211
-        return match ($category) {
212
-            AccountCategory::Asset, AccountCategory::Expense => $debitBalance - $creditBalance,
213
-            AccountCategory::Liability, AccountCategory::Equity, AccountCategory::Revenue => $creditBalance - $debitBalance,
214
-        };
215
-    }
216
-
217
-    private function calculateBalances(Account $account, string $startDate, string $endDate): array
218
-    {
219
-        $debitBalance = $this->journalEntryRepository->sumDebitAmounts($account, $startDate, $endDate);
220
-        $creditBalance = $this->journalEntryRepository->sumCreditAmounts($account, $startDate, $endDate);
221
-
222
-        return [
223
-            'debit_balance' => $debitBalance,
224
-            'credit_balance' => $creditBalance,
225
-            'net_movement' => $this->calculateNetMovementByCategory($account->category, $debitBalance, $creditBalance),
226
-        ];
227
-    }
228
-
229
-    private function calculateStartingBalances(Account $account, string $startDate): array
230
-    {
231
-        $debitBalanceBefore = $this->journalEntryRepository->sumDebitAmounts($account, $startDate);
232
-        $creditBalanceBefore = $this->journalEntryRepository->sumCreditAmounts($account, $startDate);
233
-
234
-        return [
235
-            'debit_balance_before' => $debitBalanceBefore,
236
-            'credit_balance_before' => $creditBalanceBefore,
237
-            'starting_balance' => $this->calculateNetMovementByCategory($account->category, $debitBalanceBefore, $creditBalanceBefore),
238
-        ];
239
-    }
240
-
241
-    public function getBalances(Account $account, string $startDate, string $endDate, array $fields): array
242
-    {
243
-        $balances = [];
244
-        $calculatedBalances = $this->calculateBalances($account, $startDate, $endDate);
245
-
246
-        // Calculate starting balances only if needed
247
-        $startingBalances = null;
248
-        $needStartingBalances = ! in_array($account->category, [AccountCategory::Expense, AccountCategory::Revenue], true)
249
-                                && (in_array('starting_balance', $fields) || in_array('ending_balance', $fields));
250
-
251
-        if ($needStartingBalances) {
252
-            $startingBalances = $this->calculateStartingBalances($account, $startDate);
253
-        }
254
-
255
-        foreach ($fields as $field) {
256
-            $balances[$field] = match ($field) {
257
-                'debit_balance', 'credit_balance', 'net_movement' => $calculatedBalances[$field],
258
-                'starting_balance' => $needStartingBalances ? $startingBalances['starting_balance'] : null,
259
-                'ending_balance' => $needStartingBalances ? $startingBalances['starting_balance'] + $calculatedBalances['net_movement'] : null,
260
-                default => null,
261
-            };
262
-        }
263
-
264
-        return array_filter($balances, static fn ($value) => $value !== null);
265
-    }
266
-
267 226
     public function getEarliestTransactionDate(): string
268 227
     {
269
-        $earliestDate = Transaction::oldest('posted_at')
270
-            ->value('posted_at');
228
+        $earliestDate = Transaction::min('posted_at');
271 229
 
272
-        return $earliestDate ?? now()->format('Y-m-d');
230
+        return $earliestDate ?? now()->toDateTimeString();
273 231
     }
274 232
 }

+ 30
- 13
app/Services/ExportService.php View File

@@ -12,14 +12,20 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
12 12
 
13 13
 class ExportService
14 14
 {
15
-    public function exportToCsv(Company $company, ExportableReport $report, string $startDate, string $endDate): StreamedResponse
15
+    public function exportToCsv(Company $company, ExportableReport $report, ?string $startDate = null, ?string $endDate = null): StreamedResponse
16 16
     {
17
-        $formattedStartDate = Carbon::parse($startDate)->toDateString();
18
-        $formattedEndDate = Carbon::parse($endDate)->toDateString();
17
+        if ($startDate && $endDate) {
18
+            $formattedStartDate = Carbon::parse($startDate)->toDateString();
19
+            $formattedEndDate = Carbon::parse($endDate)->toDateString();
20
+            $dateLabel = $formattedStartDate . ' to ' . $formattedEndDate;
21
+        } else {
22
+            $formattedAsOfDate = Carbon::parse($endDate)->toDateString();
23
+            $dateLabel = $formattedAsOfDate;
24
+        }
19 25
 
20 26
         $timestamp = Carbon::now()->format('Y-m-d_H-i-s');
21 27
 
22
-        $filename = $company->name . ' ' . $report->getTitle() . ' ' . $formattedStartDate . ' to ' . $formattedEndDate . ' ' . $timestamp . '.csv';
28
+        $filename = $company->name . ' ' . $report->getTitle() . ' ' . $dateLabel . ' ' . $timestamp . '.csv';
23 29
 
24 30
         $headers = [
25 31
             'Content-Type' => 'text/csv',
@@ -29,12 +35,17 @@ class ExportService
29 35
         $callback = function () use ($startDate, $endDate, $report, $company) {
30 36
             $file = fopen('php://output', 'wb');
31 37
 
32
-            $defaultStartDateFormat = Carbon::parse($startDate)->toDefaultDateFormat();
33
-            $defaultEndDateFormat = Carbon::parse($endDate)->toDefaultDateFormat();
38
+            if ($startDate && $endDate) {
39
+                $defaultStartDateFormat = Carbon::parse($startDate)->toDefaultDateFormat();
40
+                $defaultEndDateFormat = Carbon::parse($endDate)->toDefaultDateFormat();
41
+                $dateLabel = 'Date Range: ' . $defaultStartDateFormat . ' to ' . $defaultEndDateFormat;
42
+            } else {
43
+                $dateLabel = 'As of ' . Carbon::parse($endDate)->toDefaultDateFormat();
44
+            }
34 45
 
35 46
             fputcsv($file, [$report->getTitle()]);
36 47
             fputcsv($file, [$company->name]);
37
-            fputcsv($file, ['Date Range: ' . $defaultStartDateFormat . ' to ' . $defaultEndDateFormat]);
48
+            fputcsv($file, [$dateLabel]);
38 49
             fputcsv($file, []);
39 50
 
40 51
             fputcsv($file, $report->getHeaders());
@@ -92,20 +103,26 @@ class ExportService
92 103
         return response()->streamDownload($callback, $filename, $headers);
93 104
     }
94 105
 
95
-    public function exportToPdf(Company $company, ExportableReport $report, string $startDate, string $endDate): StreamedResponse
106
+    public function exportToPdf(Company $company, ExportableReport $report, ?string $startDate = null, ?string $endDate = null): StreamedResponse
96 107
     {
97
-        $formattedStartDate = Carbon::parse($startDate)->toDateString();
98
-        $formattedEndDate = Carbon::parse($endDate)->toDateString();
108
+        if ($startDate && $endDate) {
109
+            $formattedStartDate = Carbon::parse($startDate)->toDateString();
110
+            $formattedEndDate = Carbon::parse($endDate)->toDateString();
111
+            $dateLabel = $formattedStartDate . ' to ' . $formattedEndDate;
112
+        } else {
113
+            $formattedAsOfDate = Carbon::parse($endDate)->toDateString();
114
+            $dateLabel = $formattedAsOfDate;
115
+        }
99 116
 
100 117
         $timestamp = Carbon::now()->format('Y-m-d_H-i-s');
101 118
 
102
-        $filename = $company->name . ' ' . $report->getTitle() . ' ' . $formattedStartDate . ' to ' . $formattedEndDate . ' ' . $timestamp . '.pdf';
119
+        $filename = $company->name . ' ' . $report->getTitle() . ' ' . $dateLabel . ' ' . $timestamp . '.pdf';
103 120
 
104 121
         $pdf = SnappyPdf::loadView($report->getPdfView(), [
105 122
             'company' => $company,
106 123
             'report' => $report,
107
-            'startDate' => Carbon::parse($startDate)->toDefaultDateFormat(),
108
-            'endDate' => Carbon::parse($endDate)->toDefaultDateFormat(),
124
+            'startDate' => $startDate ? Carbon::parse($startDate)->toDefaultDateFormat() : null,
125
+            'endDate' => $endDate ? Carbon::parse($endDate)->toDefaultDateFormat() : null,
109 126
         ]);
110 127
 
111 128
         return response()->streamDownload(function () use ($pdf) {

+ 73
- 42
app/Services/ReportService.php View File

@@ -12,6 +12,7 @@ use App\Models\Accounting\Account;
12 12
 use App\Support\Column;
13 13
 use App\Utilities\Currency\CurrencyAccessor;
14 14
 use App\ValueObjects\Money;
15
+use Illuminate\Database\Eloquent\Builder;
15 16
 use Illuminate\Support\Carbon;
16 17
 
17 18
 class ReportService
@@ -95,7 +96,7 @@ class ReportService
95 96
         return new ReportDTO($accountCategories, $formattedReportTotalBalances, $columns);
96 97
     }
97 98
 
98
-    private function calculateAccountBalances(Account $account, AccountCategory $category): array
99
+    public function calculateAccountBalances(Account $account, AccountCategory $category): array
99 100
     {
100 101
         $balances = [
101 102
             'debit_balance' => $account->total_debit ?? 0,
@@ -116,14 +117,12 @@ class ReportService
116 117
         return $balances;
117 118
     }
118 119
 
119
-    public function calculateRetainedEarnings(string $startDate): Money
120
+    public function calculateRetainedEarnings(?string $startDate, string $endDate): Money
120 121
     {
121
-        $modifiedStartDate = Carbon::parse($this->accountService->getEarliestTransactionDate())->startOfYear()->toDateTimeString();
122
-        $endDate = Carbon::parse($startDate)->subYear()->endOfYear()->toDateTimeString();
122
+        $startDate ??= Carbon::parse($this->accountService->getEarliestTransactionDate())->toDateTimeString();
123
+        $revenueAccounts = $this->accountService->getAccountBalances($startDate, $endDate)->where('category', AccountCategory::Revenue)->get();
123 124
 
124
-        $revenueAccounts = $this->accountService->getAccountBalances($modifiedStartDate, $endDate)->where('category', AccountCategory::Revenue)->get();
125
-
126
-        $expenseAccounts = $this->accountService->getAccountBalances($modifiedStartDate, $endDate)->where('category', AccountCategory::Expense)->get();
125
+        $expenseAccounts = $this->accountService->getAccountBalances($startDate, $endDate)->where('category', AccountCategory::Expense)->get();
127 126
 
128 127
         $revenueTotal = 0;
129 128
         $expenseTotal = 0;
@@ -230,11 +229,18 @@ class ReportService
230 229
         return new ReportDTO(categories: $reportCategories, fields: $columns);
231 230
     }
232 231
 
233
-    public function buildTrialBalanceReport(string $startDate, string $endDate, array $columns = []): ReportDTO
232
+    public function buildTrialBalanceReport(string $trialBalanceType, string $asOfDate, array $columns = []): ReportDTO
234 233
     {
234
+        $asOfDateCarbon = Carbon::parse($asOfDate);
235
+        $startDateCarbon = Carbon::parse($this->accountService->getEarliestTransactionDate());
236
+
235 237
         $orderedCategories = AccountCategory::getOrderedCategories();
236 238
 
237
-        $accounts = $this->accountService->getAccountBalances($startDate, $endDate)->get();
239
+        $isPostClosingTrialBalance = $trialBalanceType === 'postClosing';
240
+
241
+        $accounts = $this->accountService->getAccountBalances($startDateCarbon->toDateTimeString(), $asOfDateCarbon->toDateTimeString())
242
+            ->when($isPostClosingTrialBalance, fn (Builder $query) => $query->whereNotIn('category', [AccountCategory::Revenue, AccountCategory::Expense]))
243
+            ->get();
238 244
 
239 245
         $balanceFields = ['debit_balance', 'credit_balance'];
240 246
 
@@ -255,7 +261,7 @@ class ReportService
255 261
 
256 262
                 $endingBalance = $accountBalances['ending_balance'] ?? $accountBalances['net_movement'];
257 263
 
258
-                $trialBalance = $this->calculateTrialBalance($account->category, $endingBalance);
264
+                $trialBalance = $this->calculateTrialBalances($account->category, $endingBalance);
259 265
 
260 266
                 foreach ($trialBalance as $balanceType => $balance) {
261 267
                     $categorySummaryBalances[$balanceType] += $balance;
@@ -268,16 +274,13 @@ class ReportService
268 274
                     $account->code,
269 275
                     $account->id,
270 276
                     $formattedAccountBalances,
271
-                    Carbon::parse($startDate)->toDateString(),
272
-                    Carbon::parse($endDate)->toDateString(),
277
+                    startDate: $startDateCarbon->toDateString(),
278
+                    endDate: $asOfDateCarbon->toDateString(),
273 279
                 );
274 280
             }
275 281
 
276
-            if ($category === AccountCategory::Equity) {
277
-                $modifiedStartDate = Carbon::parse($this->accountService->getEarliestTransactionDate())->startOfYear()->toDateString();
278
-                $modifiedEndDate = Carbon::parse($startDate)->subYear()->endOfYear()->toDateString();
279
-
280
-                $retainedEarningsAmount = $this->calculateRetainedEarnings($startDate)->getAmount();
282
+            if ($category === AccountCategory::Equity && $isPostClosingTrialBalance) {
283
+                $retainedEarningsAmount = $this->calculateRetainedEarnings($startDateCarbon->toDateTimeString(), $asOfDateCarbon->toDateTimeString())->getAmount();
281 284
                 $isCredit = $retainedEarningsAmount >= 0;
282 285
 
283 286
                 $categorySummaryBalances[$isCredit ? 'credit_balance' : 'debit_balance'] += abs($retainedEarningsAmount);
@@ -290,8 +293,8 @@ class ReportService
290 293
                         'debit_balance' => $isCredit ? 0 : abs($retainedEarningsAmount),
291 294
                         'credit_balance' => $isCredit ? $retainedEarningsAmount : 0,
292 295
                     ]),
293
-                    $modifiedStartDate,
294
-                    $modifiedEndDate,
296
+                    startDate: $startDateCarbon->toDateString(),
297
+                    endDate: $asOfDateCarbon->toDateString(),
295 298
                 );
296 299
             }
297 300
 
@@ -309,10 +312,24 @@ class ReportService
309 312
 
310 313
         $formattedReportTotalBalances = $this->formatBalances($reportTotalBalances);
311 314
 
312
-        return new ReportDTO($accountCategories, $formattedReportTotalBalances, $columns);
315
+        return new ReportDTO($accountCategories, $formattedReportTotalBalances, $columns, $trialBalanceType);
313 316
     }
314 317
 
315
-    private function calculateTrialBalance(AccountCategory $category, int $endingBalance): array
318
+    public function getRetainedEarningsBalances(string $startDate, string $endDate): AccountBalanceDTO
319
+    {
320
+        $retainedEarningsAmount = $this->calculateRetainedEarnings($startDate, $endDate)->getAmount();
321
+
322
+        $isCredit = $retainedEarningsAmount >= 0;
323
+        $retainedEarningsDebitAmount = $isCredit ? 0 : abs($retainedEarningsAmount);
324
+        $retainedEarningsCreditAmount = $isCredit ? $retainedEarningsAmount : 0;
325
+
326
+        return $this->formatBalances([
327
+            'debit_balance' => $retainedEarningsDebitAmount,
328
+            'credit_balance' => $retainedEarningsCreditAmount,
329
+        ]);
330
+    }
331
+
332
+    public function calculateTrialBalances(AccountCategory $category, int $endingBalance): array
316 333
     {
317 334
         if ($category->isNormalDebitBalance()) {
318 335
             if ($endingBalance >= 0) {
@@ -331,43 +348,56 @@ class ReportService
331 348
 
332 349
     public function buildIncomeStatementReport(string $startDate, string $endDate, array $columns = []): ReportDTO
333 350
     {
334
-        $accounts = $this->accountService->getAccountBalances($startDate, $endDate)->get();
351
+        // Query only relevant accounts and sort them at the query level
352
+        $revenueAccounts = $this->accountService->getAccountBalances($startDate, $endDate)
353
+            ->where('category', AccountCategory::Revenue)
354
+            ->orderByRaw('LENGTH(code), code')
355
+            ->get();
356
+
357
+        $cogsAccounts = $this->accountService->getAccountBalances($startDate, $endDate)
358
+            ->whereRelation('subtype', 'name', 'Cost of Goods Sold')
359
+            ->orderByRaw('LENGTH(code), code')
360
+            ->get();
361
+
362
+        $expenseAccounts = $this->accountService->getAccountBalances($startDate, $endDate)
363
+            ->where('category', AccountCategory::Expense)
364
+            ->whereRelation('subtype', 'name', '!=', 'Cost of Goods Sold')
365
+            ->orderByRaw('LENGTH(code), code')
366
+            ->get();
335 367
 
336 368
         $accountCategories = [];
337 369
         $totalRevenue = 0;
338
-        $cogs = 0;
370
+        $totalCogs = 0;
339 371
         $totalExpenses = 0;
340 372
 
373
+        // Define category groups
341 374
         $categoryGroups = [
342
-            'Revenue' => [
343
-                'accounts' => $accounts->where('category', AccountCategory::Revenue),
375
+            AccountCategory::Revenue->getPluralLabel() => [
376
+                'accounts' => $revenueAccounts,
344 377
                 'total' => &$totalRevenue,
345 378
             ],
346 379
             'Cost of Goods Sold' => [
347
-                'accounts' => $accounts->where('subtype.name', 'Cost of Goods Sold'),
348
-                'total' => &$cogs,
380
+                'accounts' => $cogsAccounts,
381
+                'total' => &$totalCogs,
349 382
             ],
350
-            'Expenses' => [
351
-                'accounts' => $accounts->where('category', AccountCategory::Expense)->where('subtype.name', '!=', 'Cost of Goods Sold'),
383
+            AccountCategory::Expense->getPluralLabel() => [
384
+                'accounts' => $expenseAccounts,
352 385
                 'total' => &$totalExpenses,
353 386
             ],
354 387
         ];
355 388
 
389
+        // Process each category group
356 390
         foreach ($categoryGroups as $label => $group) {
357 391
             $categoryAccounts = [];
358 392
             $netMovement = 0;
359 393
 
360
-            foreach ($group['accounts']->sortBy('code', SORT_NATURAL) as $account) {
361
-                $category = null;
362
-
363
-                if ($label === 'Revenue') {
364
-                    $category = AccountCategory::Revenue;
365
-                } elseif ($label === 'Expenses') {
366
-                    $category = AccountCategory::Expense;
367
-                } elseif ($label === 'Cost of Goods Sold') {
368
-                    // COGS is treated as part of Expenses, so we use AccountCategory::Expense
369
-                    $category = AccountCategory::Expense;
370
-                }
394
+            foreach ($group['accounts'] as $account) {
395
+                // Use the category type based on label
396
+                $category = match ($label) {
397
+                    AccountCategory::Revenue->getPluralLabel() => AccountCategory::Revenue,
398
+                    AccountCategory::Expense->getPluralLabel(), 'Cost of Goods Sold' => AccountCategory::Expense,
399
+                    default => null
400
+                };
371 401
 
372 402
                 if ($category !== null) {
373 403
                     $accountBalances = $this->calculateAccountBalances($account, $category);
@@ -388,11 +418,12 @@ class ReportService
388 418
 
389 419
             $accountCategories[$label] = new AccountCategoryDTO(
390 420
                 $categoryAccounts,
391
-                $this->formatBalances(['net_movement' => $netMovement]),
421
+                $this->formatBalances(['net_movement' => $netMovement])
392 422
             );
393 423
         }
394 424
 
395
-        $grossProfit = $totalRevenue - $cogs;
425
+        // Calculate gross and net profit
426
+        $grossProfit = $totalRevenue - $totalCogs;
396 427
         $netProfit = $grossProfit - $totalExpenses;
397 428
         $formattedReportTotalBalances = $this->formatBalances(['net_movement' => $netProfit]);
398 429
 

+ 137
- 0
app/Services/TransactionService.php View File

@@ -4,12 +4,17 @@ namespace App\Services;
4 4
 
5 5
 use App\Enums\Accounting\AccountCategory;
6 6
 use App\Enums\Accounting\AccountType;
7
+use App\Enums\Accounting\JournalEntryType;
7 8
 use App\Enums\Accounting\TransactionType;
8 9
 use App\Models\Accounting\Account;
10
+use App\Models\Accounting\JournalEntry;
9 11
 use App\Models\Accounting\Transaction;
10 12
 use App\Models\Banking\BankAccount;
11 13
 use App\Models\Company;
14
+use App\Utilities\Currency\CurrencyAccessor;
15
+use App\Utilities\Currency\CurrencyConverter;
12 16
 use Illuminate\Support\Carbon;
17
+use Illuminate\Support\Facades\DB;
13 18
 
14 19
 class TransactionService
15 20
 {
@@ -142,4 +147,136 @@ class TransactionService
142 147
             ->where('name', $name)
143 148
             ->firstOrFail();
144 149
     }
150
+
151
+    public function createJournalEntries(Transaction $transaction): void
152
+    {
153
+        // Additional check to avoid duplication during replication
154
+        if ($transaction->journalEntries()->exists() || $transaction->type->isJournal() || str_starts_with($transaction->description, '(Copy of)')) {
155
+            return;
156
+        }
157
+
158
+        [$debitAccount, $creditAccount] = $this->determineAccounts($transaction);
159
+
160
+        if ($debitAccount === null || $creditAccount === null) {
161
+            return;
162
+        }
163
+
164
+        $this->createJournalEntriesForTransaction($transaction, $debitAccount, $creditAccount);
165
+    }
166
+
167
+    public function updateJournalEntries(Transaction $transaction): void
168
+    {
169
+        if ($transaction->type->isJournal() || $this->hasRelevantChanges($transaction) === false) {
170
+            return;
171
+        }
172
+
173
+        $journalEntries = $transaction->journalEntries;
174
+
175
+        $debitEntry = $journalEntries->where('type', JournalEntryType::Debit)->first();
176
+        $creditEntry = $journalEntries->where('type', JournalEntryType::Credit)->first();
177
+
178
+        if ($debitEntry === null || $creditEntry === null) {
179
+            return;
180
+        }
181
+
182
+        [$debitAccount, $creditAccount] = $this->determineAccounts($transaction);
183
+
184
+        if ($debitAccount === null || $creditAccount === null) {
185
+            return;
186
+        }
187
+
188
+        $convertedTransactionAmount = $this->getConvertedTransactionAmount($transaction);
189
+
190
+        DB::transaction(function () use ($debitEntry, $debitAccount, $convertedTransactionAmount, $creditEntry, $creditAccount) {
191
+            $this->updateJournalEntryForTransaction($debitEntry, $debitAccount, $convertedTransactionAmount);
192
+            $this->updateJournalEntryForTransaction($creditEntry, $creditAccount, $convertedTransactionAmount);
193
+        });
194
+    }
195
+
196
+    public function deleteJournalEntries(Transaction $transaction): void
197
+    {
198
+        DB::transaction(static function () use ($transaction) {
199
+            $transaction->journalEntries()->each(fn (JournalEntry $entry) => $entry->delete());
200
+        });
201
+    }
202
+
203
+    private function determineAccounts(Transaction $transaction): array
204
+    {
205
+        $chartAccount = $transaction->account;
206
+        $bankAccount = $transaction->bankAccount?->account;
207
+
208
+        if ($transaction->type->isTransfer()) {
209
+            // Essentially a withdrawal from the bank account and a deposit to the chart account (which is a bank account)
210
+            // Credit: bankAccount (source of funds, money is being withdrawn)
211
+            // Debit: chartAccount (destination of funds, money is being deposited)
212
+            return [$chartAccount, $bankAccount];
213
+        }
214
+
215
+        $debitAccount = $transaction->type->isWithdrawal() ? $chartAccount : $bankAccount;
216
+        $creditAccount = $transaction->type->isWithdrawal() ? $bankAccount : $chartAccount;
217
+
218
+        return [$debitAccount, $creditAccount];
219
+    }
220
+
221
+    private function createJournalEntriesForTransaction(Transaction $transaction, Account $debitAccount, Account $creditAccount): void
222
+    {
223
+        $convertedTransactionAmount = $this->getConvertedTransactionAmount($transaction);
224
+
225
+        DB::transaction(function () use ($debitAccount, $transaction, $convertedTransactionAmount, $creditAccount) {
226
+            $debitAccount->journalEntries()->create([
227
+                'company_id' => $transaction->company_id,
228
+                'transaction_id' => $transaction->id,
229
+                'type' => JournalEntryType::Debit,
230
+                'amount' => $convertedTransactionAmount,
231
+                'description' => $transaction->description,
232
+            ]);
233
+
234
+            $creditAccount->journalEntries()->create([
235
+                'company_id' => $transaction->company_id,
236
+                'transaction_id' => $transaction->id,
237
+                'type' => JournalEntryType::Credit,
238
+                'amount' => $convertedTransactionAmount,
239
+                'description' => $transaction->description,
240
+            ]);
241
+        });
242
+    }
243
+
244
+    private function getConvertedTransactionAmount(Transaction $transaction): string
245
+    {
246
+        $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
247
+        $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
248
+        $chartAccountCurrency = $transaction->account->currency_code;
249
+
250
+        if ($bankAccountCurrency !== $defaultCurrency) {
251
+            return $this->convertToDefaultCurrency($transaction->amount, $bankAccountCurrency, $defaultCurrency);
252
+        } elseif ($chartAccountCurrency !== $defaultCurrency) {
253
+            return $this->convertToDefaultCurrency($transaction->amount, $chartAccountCurrency, $defaultCurrency);
254
+        }
255
+
256
+        return $transaction->amount;
257
+    }
258
+
259
+    private function convertToDefaultCurrency(string $amount, string $fromCurrency, string $toCurrency): string
260
+    {
261
+        $amountInCents = CurrencyConverter::prepareForAccessor($amount, $fromCurrency);
262
+
263
+        $convertedAmountInCents = CurrencyConverter::convertBalance($amountInCents, $fromCurrency, $toCurrency);
264
+
265
+        return CurrencyConverter::prepareForMutator($convertedAmountInCents, $toCurrency);
266
+    }
267
+
268
+    private function hasRelevantChanges(Transaction $transaction): bool
269
+    {
270
+        return $transaction->wasChanged(['amount', 'account_id', 'bank_account_id', 'type']);
271
+    }
272
+
273
+    private function updateJournalEntryForTransaction(JournalEntry $journalEntry, Account $account, string $convertedTransactionAmount): void
274
+    {
275
+        DB::transaction(static function () use ($journalEntry, $account, $convertedTransactionAmount) {
276
+            $journalEntry->update([
277
+                'account_id' => $account->id,
278
+                'amount' => $convertedTransactionAmount,
279
+            ]);
280
+        });
281
+    }
145 282
 }

+ 61
- 0
app/Testing/TestsReport.php View File

@@ -0,0 +1,61 @@
1
+<?php
2
+
3
+namespace App\Testing;
4
+
5
+use App\Contracts\ExportableReport;
6
+use Closure;
7
+use Livewire\Features\SupportTesting\Testable;
8
+
9
+/**
10
+ * @mixin Testable
11
+ */
12
+class TestsReport
13
+{
14
+    /**
15
+     * Asserts the report table data.
16
+     */
17
+    public function assertReportTableData(): Closure
18
+    {
19
+        return function (): static {
20
+            /** @var ExportableReport $report */
21
+            $report = $this->get('report');
22
+
23
+            // Assert headers
24
+            $this->assertSeeTextInOrder($report->getHeaders());
25
+
26
+            // Assert categories, headers, data, and summaries
27
+            $categories = $report->getCategories();
28
+            foreach ($categories as $category) {
29
+                $header = $category->header;
30
+                $data = $category->data;
31
+                $summary = $category->summary;
32
+
33
+                // Assert header
34
+                $this->assertSeeTextInOrder($header);
35
+
36
+                // Assert data rows
37
+                foreach ($data as $row) {
38
+                    $flatRow = [];
39
+
40
+                    foreach ($row as $value) {
41
+                        if (is_array($value)) {
42
+                            $flatRow[] = $value['name'];
43
+                        } else {
44
+                            $flatRow[] = $value;
45
+                        }
46
+                    }
47
+
48
+                    $this->assertSeeTextInOrder($flatRow);
49
+                }
50
+
51
+                // Assert summary
52
+                $this->assertSeeTextInOrder($summary);
53
+            }
54
+
55
+            // Assert overall totals
56
+            $this->assertSeeTextInOrder($report->getOverallTotals());
57
+
58
+            return $this;
59
+        };
60
+    }
61
+}

+ 4
- 1
app/Transformers/TrialBalanceReportTransformer.php View File

@@ -10,7 +10,10 @@ class TrialBalanceReportTransformer extends BaseReportTransformer
10 10
 {
11 11
     public function getTitle(): string
12 12
     {
13
-        return 'Trial Balance';
13
+        return match ($this->report->reportType) {
14
+            'postClosing' => 'Post-Closing Trial Balance',
15
+            default => 'Standard Trial Balance',
16
+        };
14 17
     }
15 18
 
16 19
     public function getHeaders(): array

+ 0
- 33
app/Utilities/DocumentNumber.php View File

@@ -1,33 +0,0 @@
1
-<?php
2
-
3
-namespace App\Utilities;
4
-
5
-use App\Contracts\DocumentNumber as DocumentNumberInterface;
6
-use Illuminate\Database\Eloquent\Model;
7
-
8
-class DocumentNumber implements DocumentNumberInterface
9
-{
10
-    public function getNextNumber(?Model $model, ?string $type, int | string $number, string $prefix, int | string $digits, ?bool $padded = false): string
11
-    {
12
-        if ($model) {
13
-            $numberNext = $model?->newQuery()
14
-                ->where('type', $type)
15
-                ->value($number);
16
-        } else {
17
-            $numberNext = $number;
18
-        }
19
-
20
-        if ($padded) {
21
-            return $prefix . str_pad($numberNext, $digits, '0', STR_PAD_LEFT);
22
-        }
23
-
24
-        return $numberNext;
25
-    }
26
-
27
-    public function incrementNumber(Model $model, string $type): void
28
-    {
29
-        $model->newQuery()
30
-            ->where('type', $type)
31
-            ->increment('number_next');
32
-    }
33
-}

+ 1
- 1
app/View/Models/InvoiceViewModel.php View File

@@ -80,7 +80,7 @@ class InvoiceViewModel
80 80
 
81 81
     public function invoice_number(): string
82 82
     {
83
-        return DocumentDefault::getNumberNext(padded: true, format: true, prefix: $this->number_prefix(), digits: $this->number_digits(), next: $this->number_next());
83
+        return $this->invoice->getNumberNext(padded: true, format: true, prefix: $this->number_prefix(), digits: $this->number_digits(), next: $this->number_next());
84 84
     }
85 85
 
86 86
     // Invoice date related methods

+ 3
- 3
composer.json View File

@@ -17,8 +17,7 @@
17 17
         "andrewdwallo/transmatic": "^1.1",
18 18
         "awcodes/filament-table-repeater": "^3.0",
19 19
         "barryvdh/laravel-snappy": "^1.0",
20
-        "bezhansalleh/filament-panel-switch": "^1.0",
21
-        "filament/filament": "^3.2.29",
20
+        "filament/filament": "^3.2.115",
22 21
         "guava/filament-clusters": "^1.1",
23 22
         "guzzlehttp/guzzle": "^7.8",
24 23
         "laravel/framework": "^11.0",
@@ -34,7 +33,8 @@
34 33
         "laravel/sail": "^1.26",
35 34
         "mockery/mockery": "^1.6",
36 35
         "nunomaduro/collision": "^8.0",
37
-        "phpunit/phpunit": "^10.5",
36
+        "pestphp/pest": "^3.0",
37
+        "pestphp/pest-plugin-livewire": "^3.0",
38 38
         "spatie/laravel-ignition": "^2.4",
39 39
         "spatie/laravel-ray": "^1.36"
40 40
     },

+ 1491
- 674
composer.lock
File diff suppressed because it is too large
View File


+ 68
- 30
database/factories/Accounting/TransactionFactory.php View File

@@ -2,13 +2,16 @@
2 2
 
3 3
 namespace Database\Factories\Accounting;
4 4
 
5
-use App\Enums\Accounting\JournalEntryType;
5
+use App\Enums\Accounting\AccountType;
6 6
 use App\Enums\Accounting\TransactionType;
7 7
 use App\Models\Accounting\Account;
8 8
 use App\Models\Accounting\Transaction;
9 9
 use App\Models\Banking\BankAccount;
10 10
 use App\Models\Company;
11
+use App\Models\Setting\CompanyDefault;
12
+use App\Services\TransactionService;
11 13
 use Illuminate\Database\Eloquent\Factories\Factory;
14
+use Illuminate\Support\Facades\DB;
12 15
 
13 16
 /**
14 17
  * @extends Factory<Transaction>
@@ -45,35 +48,9 @@ class TransactionFactory extends Factory
45 48
     public function configure(): static
46 49
     {
47 50
         return $this->afterCreating(function (Transaction $transaction) {
48
-            $chartAccount = $transaction->account;
49
-            $bankAccount = $transaction->bankAccount->account;
50
-
51
-            $debitAccount = $transaction->type->isWithdrawal() ? $chartAccount : $bankAccount;
52
-            $creditAccount = $transaction->type->isWithdrawal() ? $bankAccount : $chartAccount;
53
-
54
-            if ($debitAccount === null || $creditAccount === null) {
55
-                return;
51
+            if (DB::getDefaultConnection() === 'sqlite') {
52
+                app(TransactionService::class)->createJournalEntries($transaction);
56 53
             }
57
-
58
-            $debitAccount->journalEntries()->create([
59
-                'company_id' => $transaction->company_id,
60
-                'transaction_id' => $transaction->id,
61
-                'type' => JournalEntryType::Debit,
62
-                'amount' => $transaction->amount,
63
-                'description' => $transaction->description,
64
-                'created_by' => $transaction->created_by,
65
-                'updated_by' => $transaction->updated_by,
66
-            ]);
67
-
68
-            $creditAccount->journalEntries()->create([
69
-                'company_id' => $transaction->company_id,
70
-                'transaction_id' => $transaction->id,
71
-                'type' => JournalEntryType::Credit,
72
-                'amount' => $transaction->amount,
73
-                'description' => $transaction->description,
74
-                'created_by' => $transaction->created_by,
75
-                'updated_by' => $transaction->updated_by,
76
-            ]);
77 54
         });
78 55
     }
79 56
 
@@ -93,7 +70,15 @@ class TransactionFactory extends Factory
93 70
                 ->where('company_id', $company->id)
94 71
                 ->where('id', '<>', $accountIdForBankAccount)
95 72
                 ->inRandomOrder()
96
-                ->firstOrFail();
73
+                ->first();
74
+
75
+            // If no matching account is found, use a fallback
76
+            if (! $account) {
77
+                $account = Account::where('company_id', $company->id)
78
+                    ->where('id', '<>', $accountIdForBankAccount)
79
+                    ->inRandomOrder()
80
+                    ->firstOrFail(); // Ensure there is at least some account
81
+            }
97 82
 
98 83
             return [
99 84
                 'company_id' => $company->id,
@@ -103,4 +88,57 @@ class TransactionFactory extends Factory
103 88
             ];
104 89
         });
105 90
     }
91
+
92
+    public function forDefaultBankAccount(): static
93
+    {
94
+        return $this->state(function (array $attributes) {
95
+            $defaultBankAccount = CompanyDefault::first()->bankAccount;
96
+
97
+            return [
98
+                'bank_account_id' => $defaultBankAccount->id,
99
+            ];
100
+        });
101
+    }
102
+
103
+    public function forUncategorizedRevenue(): static
104
+    {
105
+        return $this->state(function (array $attributes) {
106
+            $account = Account::where('type', AccountType::UncategorizedRevenue)->firstOrFail();
107
+
108
+            return [
109
+                'account_id' => $account->id,
110
+            ];
111
+        });
112
+    }
113
+
114
+    public function forUncategorizedExpense(): static
115
+    {
116
+        return $this->state(function (array $attributes) {
117
+            $account = Account::where('type', AccountType::UncategorizedExpense)->firstOrFail();
118
+
119
+            return [
120
+                'account_id' => $account->id,
121
+            ];
122
+        });
123
+    }
124
+
125
+    public function asDeposit(int $amount): static
126
+    {
127
+        return $this->state(function () use ($amount) {
128
+            return [
129
+                'type' => TransactionType::Deposit,
130
+                'amount' => $amount,
131
+            ];
132
+        });
133
+    }
134
+
135
+    public function asWithdrawal(int $amount): static
136
+    {
137
+        return $this->state(function () use ($amount) {
138
+            return [
139
+                'type' => TransactionType::Withdrawal,
140
+                'amount' => $amount,
141
+            ];
142
+        });
143
+    }
106 144
 }

+ 40
- 0
database/factories/CompanyFactory.php View File

@@ -3,8 +3,12 @@
3 3
 namespace Database\Factories;
4 4
 
5 5
 use App\Models\Company;
6
+use App\Models\Setting\CompanyProfile;
6 7
 use App\Models\User;
8
+use App\Services\CompanyDefaultService;
9
+use Database\Factories\Accounting\TransactionFactory;
7 10
 use Illuminate\Database\Eloquent\Factories\Factory;
11
+use Illuminate\Support\Facades\DB;
8 12
 
9 13
 class CompanyFactory extends Factory
10 14
 {
@@ -28,4 +32,40 @@ class CompanyFactory extends Factory
28 32
             'personal_company' => true,
29 33
         ];
30 34
     }
35
+
36
+    public function withCompanyProfile(): self
37
+    {
38
+        return $this->afterCreating(function (Company $company) {
39
+            CompanyProfile::factory()->forCompany($company)->create();
40
+        });
41
+    }
42
+
43
+    /**
44
+     * Set up default settings for the company after creation.
45
+     */
46
+    public function withCompanyDefaults(): self
47
+    {
48
+        return $this->afterCreating(function (Company $company) {
49
+            DB::transaction(function () use ($company) {
50
+                $countryCode = $company->profile->country;
51
+                $companyDefaultService = app(CompanyDefaultService::class);
52
+                $companyDefaultService->createCompanyDefaults($company, $company->owner, 'USD', $countryCode, 'en');
53
+            });
54
+        });
55
+    }
56
+
57
+    public function withTransactions(int $count = 2000): self
58
+    {
59
+        return $this->afterCreating(function (Company $company) use ($count) {
60
+            $defaultBankAccount = $company->default->bankAccount;
61
+
62
+            TransactionFactory::new()
63
+                ->forCompanyAndBankAccount($company, $defaultBankAccount)
64
+                ->count($count)
65
+                ->createQuietly([
66
+                    'created_by' => $company->user_id,
67
+                    'updated_by' => $company->user_id,
68
+                ]);
69
+        });
70
+    }
31 71
 }

+ 18
- 10
database/factories/Setting/CompanyProfileFactory.php View File

@@ -3,8 +3,8 @@
3 3
 namespace Database\Factories\Setting;
4 4
 
5 5
 use App\Enums\Setting\EntityType;
6
-use App\Faker\PhoneNumber;
7 6
 use App\Faker\State;
7
+use App\Models\Company;
8 8
 use App\Models\Setting\CompanyProfile;
9 9
 use Illuminate\Database\Eloquent\Factories\Factory;
10 10
 
@@ -25,26 +25,34 @@ class CompanyProfileFactory extends Factory
25 25
      */
26 26
     public function definition(): array
27 27
     {
28
+        $countryCode = $this->faker->countryCode;
29
+
28 30
         return [
29 31
             'address' => $this->faker->streetAddress,
30 32
             'zip_code' => $this->faker->postcode,
33
+            'state_id' => $this->faker->state($countryCode),
34
+            'country' => $countryCode,
35
+            'phone_number' => $this->faker->phoneNumberForCountryCode($countryCode),
31 36
             'email' => $this->faker->email,
32 37
             'entity_type' => $this->faker->randomElement(EntityType::class),
33 38
         ];
34 39
     }
35 40
 
36
-    public function withCountry(string $code): static
41
+    public function withCountry(string $code): self
37 42
     {
38
-        /** @var PhoneNumber $phoneFaker */
39
-        $phoneFaker = $this->faker;
40
-
41
-        /** @var State $stateFaker */
42
-        $stateFaker = $this->faker;
43
-
44 43
         return $this->state([
45 44
             'country' => $code,
46
-            'state_id' => $stateFaker->state($code),
47
-            'phone_number' => $phoneFaker->phoneNumberForCountryCode($code),
45
+            'state_id' => $this->faker->state($code),
46
+            'phone_number' => $this->faker->phoneNumberForCountryCode($code),
47
+        ]);
48
+    }
49
+
50
+    public function forCompany(Company $company): self
51
+    {
52
+        return $this->state([
53
+            'company_id' => $company->id,
54
+            'created_by' => $company->owner->id,
55
+            'updated_by' => $company->owner->id,
48 56
         ]);
49 57
     }
50 58
 }

+ 9
- 36
database/factories/UserFactory.php View File

@@ -3,12 +3,8 @@
3 3
 namespace Database\Factories;
4 4
 
5 5
 use App\Models\Company;
6
-use App\Models\Setting\CompanyProfile;
7 6
 use App\Models\User;
8
-use App\Services\CompanyDefaultService;
9
-use Database\Factories\Accounting\TransactionFactory;
10 7
 use Illuminate\Database\Eloquent\Factories\Factory;
11
-use Illuminate\Support\Facades\DB;
12 8
 use Illuminate\Support\Facades\Hash;
13 9
 use Illuminate\Support\Str;
14 10
 use Wallo\FilamentCompanies\FilamentCompanies;
@@ -58,46 +54,23 @@ class UserFactory extends Factory
58 54
     /**
59 55
      * Indicate that the user should have a personal company.
60 56
      */
61
-    public function withPersonalCompany(): static
57
+    public function withPersonalCompany(?callable $callback = null): static
62 58
     {
63 59
         if (! FilamentCompanies::hasCompanyFeatures()) {
64 60
             return $this->state([]);
65 61
         }
66 62
 
67
-        $countryCode = $this->faker->countryCode;
68
-
69
-        return $this->afterCreating(function (User $user) use ($countryCode) {
63
+        return $this->has(
70 64
             Company::factory()
71
-                ->has(
72
-                    CompanyProfile::factory()
73
-                        ->withCountry($countryCode)
74
-                        ->state([
75
-                            'created_by' => $user->id,
76
-                            'updated_by' => $user->id,
77
-                        ]),
78
-                    'profile'
79
-                )
80
-                ->afterCreating(function (Company $company) use ($user, $countryCode) {
81
-                    DB::transaction(function () use ($company, $user, $countryCode) {
82
-                        $companyDefaultService = app()->make(CompanyDefaultService::class);
83
-                        $companyDefaultService->createCompanyDefaults($company, $user, 'USD', $countryCode, 'en');
84
-
85
-                        $defaultBankAccount = $company->default->bankAccount;
86
-
87
-                        TransactionFactory::new()
88
-                            ->forCompanyAndBankAccount($company, $defaultBankAccount)
89
-                            ->count(2000)
90
-                            ->createQuietly([
91
-                                'created_by' => $user->id,
92
-                                'updated_by' => $user->id,
93
-                            ]);
94
-                    });
95
-                })
96
-                ->create([
65
+                ->withCompanyProfile()
66
+                ->withCompanyDefaults()
67
+                ->state(fn (array $attributes, User $user) => [
97 68
                     'name' => $user->name . '\'s Company',
98 69
                     'user_id' => $user->id,
99 70
                     'personal_company' => true,
100
-                ]);
101
-        });
71
+                ])
72
+                ->when(is_callable($callback), $callback),
73
+            'ownedCompanies'
74
+        );
102 75
     }
103 76
 }

+ 4
- 1
database/seeders/DatabaseSeeder.php View File

@@ -3,6 +3,7 @@
3 3
 namespace Database\Seeders;
4 4
 
5 5
 use App\Models\User;
6
+use Database\Factories\CompanyFactory;
6 7
 use Illuminate\Database\Seeder;
7 8
 
8 9
 class DatabaseSeeder extends Seeder
@@ -14,7 +15,9 @@ class DatabaseSeeder extends Seeder
14 15
     {
15 16
         // Create a single admin user and their personal company
16 17
         $adminUser = User::factory()
17
-            ->withPersonalCompany()  // Ensures the user has a personal company created alongside
18
+            ->withPersonalCompany(function (CompanyFactory $factory) {
19
+                return $factory->withTransactions();
20
+            })
18 21
             ->create([
19 22
                 'name' => 'Admin',
20 23
                 'email' => 'admin@gmail.com',

+ 21
- 0
database/seeders/TestDatabaseSeeder.php View File

@@ -0,0 +1,21 @@
1
+<?php
2
+
3
+namespace Database\Seeders;
4
+
5
+use App\Models\User;
6
+use Illuminate\Database\Seeder;
7
+
8
+class TestDatabaseSeeder extends Seeder
9
+{
10
+    public function run(): void
11
+    {
12
+        User::factory()
13
+            ->withPersonalCompany()
14
+            ->createQuietly([
15
+                'name' => 'Test Company Owner',
16
+                'email' => 'test@gmail.com',
17
+                'password' => bcrypt('password'),
18
+                'current_company_id' => 1,
19
+            ]);
20
+    }
21
+}

+ 125
- 125
package-lock.json View File

@@ -5,15 +5,15 @@
5 5
     "packages": {
6 6
         "": {
7 7
             "devDependencies": {
8
-                "@tailwindcss/forms": "^0.5.7",
9
-                "@tailwindcss/typography": "^0.5.13",
10
-                "autoprefixer": "^10.4.19",
11
-                "axios": "^1.7.2",
8
+                "@tailwindcss/forms": "^0.5.9",
9
+                "@tailwindcss/typography": "^0.5.15",
10
+                "autoprefixer": "^10.4.20",
11
+                "axios": "^1.7.7",
12 12
                 "laravel-vite-plugin": "^1.0",
13
-                "postcss": "^8.4.38",
14
-                "postcss-nesting": "^12.1.5",
15
-                "tailwindcss": "^3.4.4",
16
-                "vite": "^5.3"
13
+                "postcss": "^8.4.47",
14
+                "postcss-nesting": "^13.0.0",
15
+                "tailwindcss": "^3.4.13",
16
+                "vite": "^5.4"
17 17
             }
18 18
         },
19 19
         "node_modules/@alloc/quick-lru": {
@@ -541,9 +541,9 @@
541 541
             }
542 542
         },
543 543
         "node_modules/@rollup/rollup-android-arm-eabi": {
544
-            "version": "4.21.2",
545
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.2.tgz",
546
-            "integrity": "sha512-fSuPrt0ZO8uXeS+xP3b+yYTCBUd05MoSp2N/MFOgjhhUhMmchXlpTQrTpI8T+YAwAQuK7MafsCOxW7VrPMrJcg==",
544
+            "version": "4.22.5",
545
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.5.tgz",
546
+            "integrity": "sha512-SU5cvamg0Eyu/F+kLeMXS7GoahL+OoizlclVFX3l5Ql6yNlywJJ0OuqTzUx0v+aHhPHEB/56CT06GQrRrGNYww==",
547 547
             "cpu": [
548 548
                 "arm"
549 549
             ],
@@ -555,9 +555,9 @@
555 555
             ]
556 556
         },
557 557
         "node_modules/@rollup/rollup-android-arm64": {
558
-            "version": "4.21.2",
559
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.2.tgz",
560
-            "integrity": "sha512-xGU5ZQmPlsjQS6tzTTGwMsnKUtu0WVbl0hYpTPauvbRAnmIvpInhJtgjj3mcuJpEiuUw4v1s4BimkdfDWlh7gA==",
558
+            "version": "4.22.5",
559
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.5.tgz",
560
+            "integrity": "sha512-S4pit5BP6E5R5C8S6tgU/drvgjtYW76FBuG6+ibG3tMvlD1h9LHVF9KmlmaUBQ8Obou7hEyS+0w+IR/VtxwNMQ==",
561 561
             "cpu": [
562 562
                 "arm64"
563 563
             ],
@@ -569,9 +569,9 @@
569 569
             ]
570 570
         },
571 571
         "node_modules/@rollup/rollup-darwin-arm64": {
572
-            "version": "4.21.2",
573
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.2.tgz",
574
-            "integrity": "sha512-99AhQ3/ZMxU7jw34Sq8brzXqWH/bMnf7ZVhvLk9QU2cOepbQSVTns6qoErJmSiAvU3InRqC2RRZ5ovh1KN0d0Q==",
572
+            "version": "4.22.5",
573
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.5.tgz",
574
+            "integrity": "sha512-250ZGg4ipTL0TGvLlfACkIxS9+KLtIbn7BCZjsZj88zSg2Lvu3Xdw6dhAhfe/FjjXPVNCtcSp+WZjVsD3a/Zlw==",
575 575
             "cpu": [
576 576
                 "arm64"
577 577
             ],
@@ -583,9 +583,9 @@
583 583
             ]
584 584
         },
585 585
         "node_modules/@rollup/rollup-darwin-x64": {
586
-            "version": "4.21.2",
587
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.2.tgz",
588
-            "integrity": "sha512-ZbRaUvw2iN/y37x6dY50D8m2BnDbBjlnMPotDi/qITMJ4sIxNY33HArjikDyakhSv0+ybdUxhWxE6kTI4oX26w==",
586
+            "version": "4.22.5",
587
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.5.tgz",
588
+            "integrity": "sha512-D8brJEFg5D+QxFcW6jYANu+Rr9SlKtTenmsX5hOSzNYVrK5oLAEMTUgKWYJP+wdKyCdeSwnapLsn+OVRFycuQg==",
589 589
             "cpu": [
590 590
                 "x64"
591 591
             ],
@@ -597,9 +597,9 @@
597 597
             ]
598 598
         },
599 599
         "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
600
-            "version": "4.21.2",
601
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.2.tgz",
602
-            "integrity": "sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==",
600
+            "version": "4.22.5",
601
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.5.tgz",
602
+            "integrity": "sha512-PNqXYmdNFyWNg0ma5LdY8wP+eQfdvyaBAojAXgO7/gs0Q/6TQJVXAXe8gwW9URjbS0YAammur0fynYGiWsKlXw==",
603 603
             "cpu": [
604 604
                 "arm"
605 605
             ],
@@ -611,9 +611,9 @@
611 611
             ]
612 612
         },
613 613
         "node_modules/@rollup/rollup-linux-arm-musleabihf": {
614
-            "version": "4.21.2",
615
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.2.tgz",
616
-            "integrity": "sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==",
614
+            "version": "4.22.5",
615
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.5.tgz",
616
+            "integrity": "sha512-kSSCZOKz3HqlrEuwKd9TYv7vxPYD77vHSUvM2y0YaTGnFc8AdI5TTQRrM1yIp3tXCKrSL9A7JLoILjtad5t8pQ==",
617 617
             "cpu": [
618 618
                 "arm"
619 619
             ],
@@ -625,9 +625,9 @@
625 625
             ]
626 626
         },
627 627
         "node_modules/@rollup/rollup-linux-arm64-gnu": {
628
-            "version": "4.21.2",
629
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.2.tgz",
630
-            "integrity": "sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==",
628
+            "version": "4.22.5",
629
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.5.tgz",
630
+            "integrity": "sha512-oTXQeJHRbOnwRnRffb6bmqmUugz0glXaPyspp4gbQOPVApdpRrY/j7KP3lr7M8kTfQTyrBUzFjj5EuHAhqH4/w==",
631 631
             "cpu": [
632 632
                 "arm64"
633 633
             ],
@@ -639,9 +639,9 @@
639 639
             ]
640 640
         },
641 641
         "node_modules/@rollup/rollup-linux-arm64-musl": {
642
-            "version": "4.21.2",
643
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.2.tgz",
644
-            "integrity": "sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==",
642
+            "version": "4.22.5",
643
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.5.tgz",
644
+            "integrity": "sha512-qnOTIIs6tIGFKCHdhYitgC2XQ2X25InIbZFor5wh+mALH84qnFHvc+vmWUpyX97B0hNvwNUL4B+MB8vJvH65Fw==",
645 645
             "cpu": [
646 646
                 "arm64"
647 647
             ],
@@ -653,9 +653,9 @@
653 653
             ]
654 654
         },
655 655
         "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
656
-            "version": "4.21.2",
657
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.2.tgz",
658
-            "integrity": "sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==",
656
+            "version": "4.22.5",
657
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.5.tgz",
658
+            "integrity": "sha512-TMYu+DUdNlgBXING13rHSfUc3Ky5nLPbWs4bFnT+R6Vu3OvXkTkixvvBKk8uO4MT5Ab6lC3U7x8S8El2q5o56w==",
659 659
             "cpu": [
660 660
                 "ppc64"
661 661
             ],
@@ -667,9 +667,9 @@
667 667
             ]
668 668
         },
669 669
         "node_modules/@rollup/rollup-linux-riscv64-gnu": {
670
-            "version": "4.21.2",
671
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.2.tgz",
672
-            "integrity": "sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==",
670
+            "version": "4.22.5",
671
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.5.tgz",
672
+            "integrity": "sha512-PTQq1Kz22ZRvuhr3uURH+U/Q/a0pbxJoICGSprNLAoBEkyD3Sh9qP5I0Asn0y0wejXQBbsVMRZRxlbGFD9OK4A==",
673 673
             "cpu": [
674 674
                 "riscv64"
675 675
             ],
@@ -681,9 +681,9 @@
681 681
             ]
682 682
         },
683 683
         "node_modules/@rollup/rollup-linux-s390x-gnu": {
684
-            "version": "4.21.2",
685
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.2.tgz",
686
-            "integrity": "sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==",
684
+            "version": "4.22.5",
685
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.5.tgz",
686
+            "integrity": "sha512-bR5nCojtpuMss6TDEmf/jnBnzlo+6n1UhgwqUvRoe4VIotC7FG1IKkyJbwsT7JDsF2jxR+NTnuOwiGv0hLyDoQ==",
687 687
             "cpu": [
688 688
                 "s390x"
689 689
             ],
@@ -695,9 +695,9 @@
695 695
             ]
696 696
         },
697 697
         "node_modules/@rollup/rollup-linux-x64-gnu": {
698
-            "version": "4.21.2",
699
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.2.tgz",
700
-            "integrity": "sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==",
698
+            "version": "4.22.5",
699
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.5.tgz",
700
+            "integrity": "sha512-N0jPPhHjGShcB9/XXZQWuWBKZQnC1F36Ce3sDqWpujsGjDz/CQtOL9LgTrJ+rJC8MJeesMWrMWVLKKNR/tMOCA==",
701 701
             "cpu": [
702 702
                 "x64"
703 703
             ],
@@ -709,9 +709,9 @@
709 709
             ]
710 710
         },
711 711
         "node_modules/@rollup/rollup-linux-x64-musl": {
712
-            "version": "4.21.2",
713
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.2.tgz",
714
-            "integrity": "sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==",
712
+            "version": "4.22.5",
713
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.5.tgz",
714
+            "integrity": "sha512-uBa2e28ohzNNwjr6Uxm4XyaA1M/8aTgfF2T7UIlElLaeXkgpmIJ2EitVNQxjO9xLLLy60YqAgKn/AqSpCUkE9g==",
715 715
             "cpu": [
716 716
                 "x64"
717 717
             ],
@@ -723,9 +723,9 @@
723 723
             ]
724 724
         },
725 725
         "node_modules/@rollup/rollup-win32-arm64-msvc": {
726
-            "version": "4.21.2",
727
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.2.tgz",
728
-            "integrity": "sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==",
726
+            "version": "4.22.5",
727
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.5.tgz",
728
+            "integrity": "sha512-RXT8S1HP8AFN/Kr3tg4fuYrNxZ/pZf1HemC5Tsddc6HzgGnJm0+Lh5rAHJkDuW3StI0ynNXukidROMXYl6ew8w==",
729 729
             "cpu": [
730 730
                 "arm64"
731 731
             ],
@@ -737,9 +737,9 @@
737 737
             ]
738 738
         },
739 739
         "node_modules/@rollup/rollup-win32-ia32-msvc": {
740
-            "version": "4.21.2",
741
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.2.tgz",
742
-            "integrity": "sha512-5rA4vjlqgrpbFVVHX3qkrCo/fZTj1q0Xxpg+Z7yIo3J2AilW7t2+n6Q8Jrx+4MrYpAnjttTYF8rr7bP46BPzRw==",
740
+            "version": "4.22.5",
741
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.5.tgz",
742
+            "integrity": "sha512-ElTYOh50InL8kzyUD6XsnPit7jYCKrphmddKAe1/Ytt74apOxDq5YEcbsiKs0fR3vff3jEneMM+3I7jbqaMyBg==",
743 743
             "cpu": [
744 744
                 "ia32"
745 745
             ],
@@ -751,9 +751,9 @@
751 751
             ]
752 752
         },
753 753
         "node_modules/@rollup/rollup-win32-x64-msvc": {
754
-            "version": "4.21.2",
755
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.2.tgz",
756
-            "integrity": "sha512-6UUxd0+SKomjdzuAcp+HAmxw1FlGBnl1v2yEPSabtx4lBfdXHDVsW7+lQkgz9cNFJGY3AWR7+V8P5BqkD9L9nA==",
754
+            "version": "4.22.5",
755
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.5.tgz",
756
+            "integrity": "sha512-+lvL/4mQxSV8MukpkKyyvfwhH266COcWlXE/1qxwN08ajovta3459zrjLghYMgDerlzNwLAcFpvU+WWE5y6nAQ==",
757 757
             "cpu": [
758 758
                 "x64"
759 759
             ],
@@ -794,9 +794,9 @@
794 794
             }
795 795
         },
796 796
         "node_modules/@types/estree": {
797
-            "version": "1.0.5",
798
-            "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
799
-            "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
797
+            "version": "1.0.6",
798
+            "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
799
+            "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
800 800
             "dev": true,
801 801
             "license": "MIT"
802 802
         },
@@ -955,9 +955,9 @@
955 955
             }
956 956
         },
957 957
         "node_modules/browserslist": {
958
-            "version": "4.23.3",
959
-            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
960
-            "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
958
+            "version": "4.24.0",
959
+            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz",
960
+            "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==",
961 961
             "dev": true,
962 962
             "funding": [
963 963
                 {
@@ -975,8 +975,8 @@
975 975
             ],
976 976
             "license": "MIT",
977 977
             "dependencies": {
978
-                "caniuse-lite": "^1.0.30001646",
979
-                "electron-to-chromium": "^1.5.4",
978
+                "caniuse-lite": "^1.0.30001663",
979
+                "electron-to-chromium": "^1.5.28",
980 980
                 "node-releases": "^2.0.18",
981 981
                 "update-browserslist-db": "^1.1.0"
982 982
             },
@@ -998,9 +998,9 @@
998 998
             }
999 999
         },
1000 1000
         "node_modules/caniuse-lite": {
1001
-            "version": "1.0.30001659",
1002
-            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001659.tgz",
1003
-            "integrity": "sha512-Qxxyfv3RdHAfJcXelgf0hU4DFUVXBGTjqrBUZLUh8AtlGnsDo+CnncYtTd95+ZKfnANUOzxyIQCuU/UeBZBYoA==",
1001
+            "version": "1.0.30001664",
1002
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001664.tgz",
1003
+            "integrity": "sha512-AmE7k4dXiNKQipgn7a2xg558IRqPN3jMQY/rOsbxDhrd0tyChwbITBfiwtnqz8bi2M5mIWbxAYBvk7W7QBUS2g==",
1004 1004
             "dev": true,
1005 1005
             "funding": [
1006 1006
                 {
@@ -1159,9 +1159,9 @@
1159 1159
             "license": "MIT"
1160 1160
         },
1161 1161
         "node_modules/electron-to-chromium": {
1162
-            "version": "1.5.18",
1163
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.18.tgz",
1164
-            "integrity": "sha512-1OfuVACu+zKlmjsNdcJuVQuVE61sZOLbNM4JAQ1Rvh6EOj0/EUKhMJjRH73InPlXSh8HIJk1cVZ8pyOV/FMdUQ==",
1162
+            "version": "1.5.30",
1163
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.30.tgz",
1164
+            "integrity": "sha512-sXI35EBN4lYxzc/pIGorlymYNzDBOqkSlVRe6MkgBsW/hW1tpC/HDJ2fjG7XnjakzfLEuvdmux0Mjs6jHq4UOA==",
1165 1165
             "dev": true,
1166 1166
             "license": "ISC"
1167 1167
         },
@@ -1745,9 +1745,9 @@
1745 1745
             }
1746 1746
         },
1747 1747
         "node_modules/package-json-from-dist": {
1748
-            "version": "1.0.0",
1749
-            "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
1750
-            "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
1748
+            "version": "1.0.1",
1749
+            "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
1750
+            "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
1751 1751
             "dev": true,
1752 1752
             "license": "BlueOak-1.0.0"
1753 1753
         },
@@ -1826,9 +1826,9 @@
1826 1826
             }
1827 1827
         },
1828 1828
         "node_modules/postcss": {
1829
-            "version": "8.4.45",
1830
-            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz",
1831
-            "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==",
1829
+            "version": "8.4.47",
1830
+            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
1831
+            "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
1832 1832
             "dev": true,
1833 1833
             "funding": [
1834 1834
                 {
@@ -1847,8 +1847,8 @@
1847 1847
             "license": "MIT",
1848 1848
             "dependencies": {
1849 1849
                 "nanoid": "^3.3.7",
1850
-                "picocolors": "^1.0.1",
1851
-                "source-map-js": "^1.2.0"
1850
+                "picocolors": "^1.1.0",
1851
+                "source-map-js": "^1.2.1"
1852 1852
             },
1853 1853
             "engines": {
1854 1854
                 "node": "^10 || ^12 || >=14"
@@ -1982,9 +1982,9 @@
1982 1982
             }
1983 1983
         },
1984 1984
         "node_modules/postcss-nesting": {
1985
-            "version": "12.1.5",
1986
-            "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-12.1.5.tgz",
1987
-            "integrity": "sha512-N1NgI1PDCiAGWPTYrwqm8wpjv0bgDmkYHH72pNsqTCv9CObxjxftdYu6AKtGN+pnJa7FQjMm3v4sp8QJbFsYdQ==",
1985
+            "version": "13.0.0",
1986
+            "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.0.tgz",
1987
+            "integrity": "sha512-TCGQOizyqvEkdeTPM+t6NYwJ3EJszYE/8t8ILxw/YoeUvz2rz7aM8XTAmBWh9/DJjfaaabL88fWrsVHSPF2zgA==",
1988 1988
             "dev": true,
1989 1989
             "funding": [
1990 1990
                 {
@@ -1998,21 +1998,21 @@
1998 1998
             ],
1999 1999
             "license": "MIT-0",
2000 2000
             "dependencies": {
2001
-                "@csstools/selector-resolve-nested": "^1.1.0",
2002
-                "@csstools/selector-specificity": "^3.1.1",
2001
+                "@csstools/selector-resolve-nested": "^2.0.0",
2002
+                "@csstools/selector-specificity": "^4.0.0",
2003 2003
                 "postcss-selector-parser": "^6.1.0"
2004 2004
             },
2005 2005
             "engines": {
2006
-                "node": "^14 || ^16 || >=18"
2006
+                "node": ">=18"
2007 2007
             },
2008 2008
             "peerDependencies": {
2009 2009
                 "postcss": "^8.4"
2010 2010
             }
2011 2011
         },
2012 2012
         "node_modules/postcss-nesting/node_modules/@csstools/selector-resolve-nested": {
2013
-            "version": "1.1.0",
2014
-            "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-1.1.0.tgz",
2015
-            "integrity": "sha512-uWvSaeRcHyeNenKg8tp17EVDRkpflmdyvbE0DHo6D/GdBb6PDnCYYU6gRpXhtICMGMcahQmj2zGxwFM/WC8hCg==",
2013
+            "version": "2.0.0",
2014
+            "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-2.0.0.tgz",
2015
+            "integrity": "sha512-oklSrRvOxNeeOW1yARd4WNCs/D09cQjunGZUgSq6vM8GpzFswN+8rBZyJA29YFZhOTQ6GFzxgLDNtVbt9wPZMA==",
2016 2016
             "dev": true,
2017 2017
             "funding": [
2018 2018
                 {
@@ -2026,16 +2026,16 @@
2026 2026
             ],
2027 2027
             "license": "MIT-0",
2028 2028
             "engines": {
2029
-                "node": "^14 || ^16 || >=18"
2029
+                "node": ">=18"
2030 2030
             },
2031 2031
             "peerDependencies": {
2032
-                "postcss-selector-parser": "^6.0.13"
2032
+                "postcss-selector-parser": "^6.1.0"
2033 2033
             }
2034 2034
         },
2035 2035
         "node_modules/postcss-nesting/node_modules/@csstools/selector-specificity": {
2036
-            "version": "3.1.1",
2037
-            "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.1.1.tgz",
2038
-            "integrity": "sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==",
2036
+            "version": "4.0.0",
2037
+            "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-4.0.0.tgz",
2038
+            "integrity": "sha512-189nelqtPd8++phaHNwYovKZI0FOzH1vQEE3QhHHkNIGrg5fSs9CbYP3RvfEH5geztnIA9Jwq91wyOIwAW5JIQ==",
2039 2039
             "dev": true,
2040 2040
             "funding": [
2041 2041
                 {
@@ -2049,10 +2049,10 @@
2049 2049
             ],
2050 2050
             "license": "MIT-0",
2051 2051
             "engines": {
2052
-                "node": "^14 || ^16 || >=18"
2052
+                "node": ">=18"
2053 2053
             },
2054 2054
             "peerDependencies": {
2055
-                "postcss-selector-parser": "^6.0.13"
2055
+                "postcss-selector-parser": "^6.1.0"
2056 2056
             }
2057 2057
         },
2058 2058
         "node_modules/postcss-nesting/node_modules/postcss-selector-parser": {
@@ -2171,13 +2171,13 @@
2171 2171
             }
2172 2172
         },
2173 2173
         "node_modules/rollup": {
2174
-            "version": "4.21.2",
2175
-            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.2.tgz",
2176
-            "integrity": "sha512-e3TapAgYf9xjdLvKQCkQTnbTKd4a6jwlpQSJJFokHGaX2IVjoEqkIIhiQfqsi0cdwlOD+tQGuOd5AJkc5RngBw==",
2174
+            "version": "4.22.5",
2175
+            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.5.tgz",
2176
+            "integrity": "sha512-WoinX7GeQOFMGznEcWA1WrTQCd/tpEbMkc3nuMs9BT0CPjMdSjPMTVClwWd4pgSQwJdP65SK9mTCNvItlr5o7w==",
2177 2177
             "dev": true,
2178 2178
             "license": "MIT",
2179 2179
             "dependencies": {
2180
-                "@types/estree": "1.0.5"
2180
+                "@types/estree": "1.0.6"
2181 2181
             },
2182 2182
             "bin": {
2183 2183
                 "rollup": "dist/bin/rollup"
@@ -2187,22 +2187,22 @@
2187 2187
                 "npm": ">=8.0.0"
2188 2188
             },
2189 2189
             "optionalDependencies": {
2190
-                "@rollup/rollup-android-arm-eabi": "4.21.2",
2191
-                "@rollup/rollup-android-arm64": "4.21.2",
2192
-                "@rollup/rollup-darwin-arm64": "4.21.2",
2193
-                "@rollup/rollup-darwin-x64": "4.21.2",
2194
-                "@rollup/rollup-linux-arm-gnueabihf": "4.21.2",
2195
-                "@rollup/rollup-linux-arm-musleabihf": "4.21.2",
2196
-                "@rollup/rollup-linux-arm64-gnu": "4.21.2",
2197
-                "@rollup/rollup-linux-arm64-musl": "4.21.2",
2198
-                "@rollup/rollup-linux-powerpc64le-gnu": "4.21.2",
2199
-                "@rollup/rollup-linux-riscv64-gnu": "4.21.2",
2200
-                "@rollup/rollup-linux-s390x-gnu": "4.21.2",
2201
-                "@rollup/rollup-linux-x64-gnu": "4.21.2",
2202
-                "@rollup/rollup-linux-x64-musl": "4.21.2",
2203
-                "@rollup/rollup-win32-arm64-msvc": "4.21.2",
2204
-                "@rollup/rollup-win32-ia32-msvc": "4.21.2",
2205
-                "@rollup/rollup-win32-x64-msvc": "4.21.2",
2190
+                "@rollup/rollup-android-arm-eabi": "4.22.5",
2191
+                "@rollup/rollup-android-arm64": "4.22.5",
2192
+                "@rollup/rollup-darwin-arm64": "4.22.5",
2193
+                "@rollup/rollup-darwin-x64": "4.22.5",
2194
+                "@rollup/rollup-linux-arm-gnueabihf": "4.22.5",
2195
+                "@rollup/rollup-linux-arm-musleabihf": "4.22.5",
2196
+                "@rollup/rollup-linux-arm64-gnu": "4.22.5",
2197
+                "@rollup/rollup-linux-arm64-musl": "4.22.5",
2198
+                "@rollup/rollup-linux-powerpc64le-gnu": "4.22.5",
2199
+                "@rollup/rollup-linux-riscv64-gnu": "4.22.5",
2200
+                "@rollup/rollup-linux-s390x-gnu": "4.22.5",
2201
+                "@rollup/rollup-linux-x64-gnu": "4.22.5",
2202
+                "@rollup/rollup-linux-x64-musl": "4.22.5",
2203
+                "@rollup/rollup-win32-arm64-msvc": "4.22.5",
2204
+                "@rollup/rollup-win32-ia32-msvc": "4.22.5",
2205
+                "@rollup/rollup-win32-x64-msvc": "4.22.5",
2206 2206
                 "fsevents": "~2.3.2"
2207 2207
             }
2208 2208
         },
@@ -2417,9 +2417,9 @@
2417 2417
             }
2418 2418
         },
2419 2419
         "node_modules/tailwindcss": {
2420
-            "version": "3.4.10",
2421
-            "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz",
2422
-            "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==",
2420
+            "version": "3.4.13",
2421
+            "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz",
2422
+            "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==",
2423 2423
             "dev": true,
2424 2424
             "license": "MIT",
2425 2425
             "dependencies": {
@@ -2512,9 +2512,9 @@
2512 2512
             "license": "Apache-2.0"
2513 2513
         },
2514 2514
         "node_modules/update-browserslist-db": {
2515
-            "version": "1.1.0",
2516
-            "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
2517
-            "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==",
2515
+            "version": "1.1.1",
2516
+            "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
2517
+            "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==",
2518 2518
             "dev": true,
2519 2519
             "funding": [
2520 2520
                 {
@@ -2532,8 +2532,8 @@
2532 2532
             ],
2533 2533
             "license": "MIT",
2534 2534
             "dependencies": {
2535
-                "escalade": "^3.1.2",
2536
-                "picocolors": "^1.0.1"
2535
+                "escalade": "^3.2.0",
2536
+                "picocolors": "^1.1.0"
2537 2537
             },
2538 2538
             "bin": {
2539 2539
                 "update-browserslist-db": "cli.js"
@@ -2550,9 +2550,9 @@
2550 2550
             "license": "MIT"
2551 2551
         },
2552 2552
         "node_modules/vite": {
2553
-            "version": "5.4.3",
2554
-            "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.3.tgz",
2555
-            "integrity": "sha512-IH+nl64eq9lJjFqU+/yrRnrHPVTlgy42/+IzbOdaFDVlyLgI/wDlf+FCobXLX1cT0X5+7LMyH1mIy2xJdLfo8Q==",
2553
+            "version": "5.4.8",
2554
+            "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz",
2555
+            "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==",
2556 2556
             "dev": true,
2557 2557
             "license": "MIT",
2558 2558
             "dependencies": {

+ 8
- 8
package.json View File

@@ -6,14 +6,14 @@
6 6
         "build": "vite build"
7 7
     },
8 8
     "devDependencies": {
9
-        "@tailwindcss/forms": "^0.5.7",
10
-        "@tailwindcss/typography": "^0.5.13",
11
-        "autoprefixer": "^10.4.19",
12
-        "axios": "^1.7.2",
9
+        "@tailwindcss/forms": "^0.5.9",
10
+        "@tailwindcss/typography": "^0.5.15",
11
+        "autoprefixer": "^10.4.20",
12
+        "axios": "^1.7.7",
13 13
         "laravel-vite-plugin": "^1.0",
14
-        "postcss": "^8.4.38",
15
-        "postcss-nesting": "^12.1.5",
16
-        "tailwindcss": "^3.4.4",
17
-        "vite": "^5.3"
14
+        "postcss": "^8.4.47",
15
+        "postcss-nesting": "^13.0.0",
16
+        "tailwindcss": "^3.4.13",
17
+        "vite": "^5.4"
18 18
     }
19 19
 }

+ 2
- 2
phpunit.xml View File

@@ -22,8 +22,8 @@
22 22
         <env name="APP_MAINTENANCE_DRIVER" value="file"/>
23 23
         <env name="BCRYPT_ROUNDS" value="4"/>
24 24
         <env name="CACHE_STORE" value="array"/>
25
-        <!-- <env name="DB_CONNECTION" value="sqlite"/> -->
26
-        <!-- <env name="DB_DATABASE" value=":memory:"/> -->
25
+        <env name="DB_CONNECTION" value="mysql"/>
26
+        <env name="DB_DATABASE" value="erpsaas_test"/>
27 27
         <env name="MAIL_MAILER" value="array"/>
28 28
         <env name="PULSE_ENABLED" value="false"/>
29 29
         <env name="QUEUE_CONNECTION" value="sync"/>

+ 8
- 0
resources/css/filament/company/theme.css View File

@@ -4,6 +4,14 @@
4 4
 
5 5
 @config './tailwind.config.js';
6 6
 
7
+.fi-fo-field-wrp.report-hidden-label > div.grid.gap-y-2 > div.flex.items-center {
8
+    @apply hidden;
9
+}
10
+
11
+.fi-fo-field-wrp.report-hidden-label {
12
+    @apply lg:mt-8;
13
+}
14
+
7 15
 .choices__list.choices__list--single {
8 16
     @apply w-full;
9 17
 }

+ 1
- 0
resources/css/filament/user/tailwind.config.js View File

@@ -5,6 +5,7 @@ export default {
5 5
     content: [
6 6
         './app/Filament/User/**/*.php',
7 7
         './resources/views/filament/user/**/*.blade.php',
8
+        './resources/views/components/**/*.blade.php',
8 9
         './vendor/filament/**/*.blade.php',
9 10
         './vendor/andrewdwallo/filament-companies/resources/views/**/*.blade.php',
10 11
         './vendor/bezhansalleh/filament-panel-switch/resources/views/panel-switch-menu.blade.php',

+ 19
- 8
resources/js/TopNavigation.js View File

@@ -1,6 +1,5 @@
1 1
 document.addEventListener('DOMContentLoaded', () => {
2 2
     handleTopbarAndSidebarHover();
3
-
4 3
     handleScroll();
5 4
 });
6 5
 
@@ -10,18 +9,25 @@ const handleTopbarAndSidebarHover = () => {
10 9
 
11 10
     const addHoveredClass = () => {
12 11
         topbarNav.classList.add('topbar-hovered');
13
-        sidebarHeader.classList.add('topbar-hovered');
12
+        if (sidebarHeader) {
13
+            sidebarHeader.classList.add('topbar-hovered');
14
+        }
14 15
     };
15 16
 
16 17
     const removeHoveredClass = () => {
17 18
         topbarNav.classList.remove('topbar-hovered');
18
-        sidebarHeader.classList.remove('topbar-hovered');
19
+        if (sidebarHeader) {
20
+            sidebarHeader.classList.remove('topbar-hovered');
21
+        }
19 22
     };
20 23
 
21 24
     topbarNav.addEventListener('mouseenter', addHoveredClass);
22
-    sidebarHeader.addEventListener('mouseenter', addHoveredClass);
23 25
     topbarNav.addEventListener('mouseleave', removeHoveredClass);
24
-    sidebarHeader.addEventListener('mouseleave', removeHoveredClass);
26
+
27
+    if (sidebarHeader) {
28
+        sidebarHeader.addEventListener('mouseenter', addHoveredClass);
29
+        sidebarHeader.addEventListener('mouseleave', removeHoveredClass);
30
+    }
25 31
 };
26 32
 
27 33
 const handleScroll = () => {
@@ -31,11 +37,16 @@ const handleScroll = () => {
31 37
     window.addEventListener('scroll', () => {
32 38
         if (window.scrollY > 0) {
33 39
             topbarNav.classList.add('topbar-scrolled');
34
-            sidebarHeader.classList.add('topbar-scrolled');
40
+            if (sidebarHeader) {
41
+                sidebarHeader.classList.add('topbar-scrolled');
42
+            }
35 43
         } else {
36 44
             topbarNav.classList.remove('topbar-scrolled');
37
-            sidebarHeader.classList.remove('topbar-scrolled');
45
+            if (sidebarHeader) {
46
+                sidebarHeader.classList.remove('topbar-scrolled');
47
+            }
38 48
         }
39 49
     });
40
-}
50
+};
51
+
41 52
 

+ 5
- 1
resources/views/components/company/reports/report-pdf.blade.php View File

@@ -93,7 +93,11 @@
93 93
 <div class="header">
94 94
     <div class="title">{{ $report->getTitle() }}</div>
95 95
     <div class="company-name">{{ $company->name }}</div>
96
-    <div class="date-range">Date Range: {{ $startDate }} to {{ $endDate }}</div>
96
+    @if($startDate && $endDate)
97
+        <div class="date-range">Date Range: {{ $startDate }} to {{ $endDate }}</div>
98
+    @else
99
+        <div class="date-range">As of {{ $endDate }}</div>
100
+    @endif
97 101
 </div>
98 102
 <table class="table-class">
99 103
     <thead class="table-head">

+ 14
- 6
resources/views/components/panel-shift-dropdown.blade.php View File

@@ -1,4 +1,5 @@
1 1
 @php
2
+    $user = filament()->auth()->user();
2 3
     $items = filament()->getUserMenuItems();
3 4
     $logoutItem = $items['logout'] ?? null;
4 5
     $currentTenant = filament()->getTenant();
@@ -15,12 +16,19 @@
15 16
     <div x-on:click="toggleDropdown" class="flex cursor-pointer">
16 17
         <button
17 18
             type="button"
18
-            class="fi-tenant-menu-trigger group flex w-full items-center justify-center gap-x-3 rounded-lg p-2 text-sm font-medium outline-none transition duration-75 hover:bg-gray-100 focus-visible:bg-gray-100 dark:hover:bg-white/5 dark:focus-visible:bg-white/5"
19
+            class="flex items-center justify-center gap-x-3 rounded-lg p-2 text-sm font-medium outline-none transition duration-75 hover:bg-gray-100 focus-visible:bg-gray-100 dark:hover:bg-white/5 dark:focus-visible:bg-white/5"
19 20
         >
20
-            <x-filament-panels::avatar.tenant
21
-                :tenant="$currentTenant"
22
-                class="shrink-0"
23
-            />
21
+            @if($currentTenant)
22
+                <x-filament-panels::avatar.tenant
23
+                    :tenant="$currentTenant"
24
+                    class="shrink-0"
25
+                />
26
+            @else
27
+                <x-filament-panels::avatar.user
28
+                    :user="$user"
29
+                    class="shrink-0"
30
+                />
31
+            @endif
24 32
 
25 33
             <span class="grid justify-items-start text-start">
26 34
                 @if ($currentTenant instanceof \Filament\Models\Contracts\HasCurrentTenantLabel)
@@ -30,7 +38,7 @@
30 38
                 @endif
31 39
 
32 40
                 <span class="text-gray-950 dark:text-white">
33
-                    {{ $currentTenantName }}
41
+                    {{ $currentTenantName ?? filament()->getUserName($user) }}
34 42
                 </span>
35 43
             </span>
36 44
 

+ 0
- 1
resources/views/filament/company/pages/accounting/transactions.blade.php View File

@@ -1,4 +1,3 @@
1 1
 <x-filament-panels::page>
2
-    {{ $this->form }}
3 2
     {{ $this->table }}
4 3
 </x-filament-panels::page>

+ 2
- 2
resources/views/filament/company/pages/reports/detailed-report.blade.php View File

@@ -9,8 +9,8 @@
9 9
             <!-- Grouping Button and Column Toggle -->
10 10
             @if($this->hasToggleableColumns())
11 11
                 <x-filament-tables::column-toggle.dropdown
12
-                    :form="$this->toggleTableColumnForm"
13
-                    :trigger-action="$this->toggleColumnsAction"
12
+                    :form="$this->getTableColumnToggleForm()"
13
+                    :trigger-action="$this->getToggleColumnsTriggerAction()"
14 14
                 />
15 15
             @endif
16 16
 

+ 2
- 2
resources/views/filament/company/pages/reports/income-statement.blade.php View File

@@ -9,8 +9,8 @@
9 9
             <!-- Grouping Button and Column Toggle -->
10 10
             @if($this->hasToggleableColumns())
11 11
                 <x-filament-tables::column-toggle.dropdown
12
-                    :form="$this->toggleTableColumnForm"
13
-                    :trigger-action="$this->toggleColumnsAction"
12
+                    :form="$this->getTableColumnToggleForm()"
13
+                    :trigger-action="$this->getToggleColumnsTriggerAction()"
14 14
                 />
15 15
             @endif
16 16
 

+ 45
- 0
resources/views/filament/company/pages/reports/trial-balance.blade.php View File

@@ -0,0 +1,45 @@
1
+<x-filament-panels::page>
2
+    <x-filament::section>
3
+        <div class="flex flex-col lg:flex-row items-start lg:items-end justify-between gap-4">
4
+            <!-- Form Container -->
5
+            @if(method_exists($this, 'filtersForm'))
6
+                {{ $this->filtersForm }}
7
+            @endif
8
+
9
+            <!-- Grouping Button and Column Toggle -->
10
+            @if($this->hasToggleableColumns())
11
+                <div class="lg:mb-1">
12
+                    <x-filament-tables::column-toggle.dropdown
13
+                        :form="$this->getTableColumnToggleForm()"
14
+                        :trigger-action="$this->getToggleColumnsTriggerAction()"
15
+                    />
16
+                </div>
17
+            @endif
18
+
19
+            <div class="inline-flex items-center min-w-0 lg:min-w-[9.5rem] justify-end">
20
+                {{ $this->applyFiltersAction }}
21
+            </div>
22
+        </div>
23
+    </x-filament::section>
24
+
25
+    <x-filament-tables::container>
26
+        <div class="es-table__header-ctn"></div>
27
+        <div
28
+            class="relative divide-y divide-gray-200 overflow-x-auto dark:divide-white/10 dark:border-t-white/10 min-h-64">
29
+            <div wire:init="applyFilters" class="flex items-center justify-center w-full h-full absolute">
30
+                <div wire:loading wire:target="applyFilters">
31
+                    <x-filament::loading-indicator class="p-6 text-primary-700 dark:text-primary-300"/>
32
+                </div>
33
+            </div>
34
+
35
+            @if($this->reportLoaded)
36
+                <div wire:loading.remove wire:target="applyFilters">
37
+                    @if($this->report)
38
+                        <x-company.tables.reports.detailed-report :report="$this->report"/>
39
+                    @endif
40
+                </div>
41
+            @endif
42
+        </div>
43
+        <div class="es-table__footer-ctn border-t border-gray-200"></div>
44
+    </x-filament-tables::container>
45
+</x-filament-panels::page>

+ 0
- 0
resources/views/vendor/filament-panel-switch/.gitkeep View File


+ 0
- 107
resources/views/vendor/filament-panel-switch/panel-switch-menu.blade.php View File

@@ -1,107 +0,0 @@
1
-@php
2
-    $getUrlScheme = (string) app()->environment('production') ? 'https://' : 'http://';
3
-
4
-    $getPanelPath = fn (\Filament\Panel $panel): string => filled($domains = $panel->getDomains())
5
-            ? str(collect($domains)->first())->prepend($getUrlScheme)->toString()
6
-            : str($panel->getPath())->prepend('/')->toString();
7
-
8
-    $getHref = fn (\Filament\Panel $panel): ?string => $canSwitchPanels && $panel->getId() !== $currentPanel->getId()
9
-            ? $getPanelPath($panel)
10
-            : null;
11
-@endphp
12
-
13
-@if ($isSimple)
14
-    <x-filament::dropdown teleport placement="bottom-end">
15
-        <x-slot name="trigger">
16
-            <div x-data="{ open: false }" @click.outside="open = false">
17
-                <button type="button" @click="open = !open" class="flex items-center justify-center gap-x-2 rounded-lg px-3 py-2 text-sm font-semibold outline-none transition duration-75 hover:bg-gray-50 focus-visible:bg-gray-50 dark:hover:bg-white/5 dark:focus-visible:bg-white/5 text-gray-700 dark:text-gray-200">
18
-                    <span class="ml-4">{{  $labels[$currentPanel->getId()] ?? str($currentPanel->getId())->ucfirst() }}</span>
19
-                    <x-heroicon-m-chevron-down x-show="!open" class="w-5 h-5 text-gray-400 dark:text-gray-500" />
20
-                    <x-heroicon-m-chevron-up x-show="open" x-cloak class="w-5 h-5 text-gray-400 dark:text-gray-500" />
21
-                </button>
22
-            </div>
23
-        </x-slot>
24
-
25
-        <x-filament::dropdown.list>
26
-            @foreach ($panels as $panel)
27
-                <x-filament::dropdown.list.item
28
-                    :href="$getHref($panel)"
29
-                    :icon="$icons[$panel->getId()] ?? 'heroicon-s-square-2-stack'"
30
-                    tag="a"
31
-                >
32
-                    {{ $labels[$panel->getId()] ?? str($panel->getId())->ucfirst() }}
33
-                </x-filament::dropdown.list.item>
34
-            @endforeach
35
-        </x-filament::dropdown.list>
36
-    </x-filament::dropdown>
37
-@else
38
-    <style>
39
-        .panel-switch-modal .fi-modal-content {
40
-            align-items: center !important;
41
-            justify-content: center !important;
42
-        }
43
-    </style>
44
-    <x-filament::icon-button
45
-        x-data="{}"
46
-        icon="heroicon-o-square-3-stack-3d"
47
-        icon-alias="panels::panel-switch-modern-icon"
48
-        icon-size="lg"
49
-        @click="$dispatch('open-modal', { id: 'panel-switch' })"
50
-        label="Switch Panels"
51
-        class="text-gray-700 dark:text-primary-500"
52
-    />
53
-
54
-    <x-filament::modal
55
-        id="panel-switch"
56
-        :width="$modalWidth"
57
-        alignment="center"
58
-        display-classes="block"
59
-        :slide-over="$isSlideOver"
60
-        :sticky-header="$isSlideOver"
61
-        :heading="$heading"
62
-        class="panel-switch-modal"
63
-    >
64
-        <div
65
-            class="flex flex-wrap items-center justify-center gap-4 md:gap-6"
66
-        >
67
-            @foreach ($panels as $panel)
68
-                <!-- x-on:click="location.href = '{{ $getHref($panel) }}'" -->
69
-                <a
70
-                    href="{{ $getHref($panel) }}"
71
-                    class="flex flex-col items-center justify-center flex-1 hover:cursor-pointer group panel-switch-card"
72
-                >
73
-                    <div
74
-                        @class([
75
-                            "p-2 bg-white rounded-lg shadow-md dark:bg-gray-800 panel-switch-card-section",
76
-                            "group-hover:ring-2 group-hover:ring-primary-600" => $panel->getId() !== $currentPanel->getId(),
77
-                            "ring-2 ring-primary-600" => $panel->getId() === $currentPanel->getId(),
78
-                        ])
79
-                    >
80
-                        @if ($renderIconAsImage)
81
-                            <img
82
-                                class="rounded-lg panel-switch-card-image"
83
-                                style="width: {{ $iconSize * 4 }}px; height: {{ $iconSize * 4 }}px;"
84
-                                src="{{ $icons[$panel->getId()] ?? 'https://raw.githubusercontent.com/bezhanSalleh/filament-panel-switch/3.x/art/banner.jpg' }}"
85
-                                alt="Panel Image"
86
-                            >
87
-                        @else
88
-                            @php
89
-                                $iconName = $icons[$panel->getId()] ?? 'heroicon-s-square-2-stack' ;
90
-                            @endphp
91
-                            @svg($iconName, 'text-primary-600 panel-switch-card-icon', ['style' => 'width: ' . ($iconSize * 4) . 'px; height: ' . ($iconSize * 4). 'px;'])
92
-                        @endif
93
-                    </div>
94
-                    <span
95
-                        @class([
96
-                            "mt-2 text-sm font-medium text-center text-gray-400 dark:text-gray-200 break-words panel-switch-card-title",
97
-                            "text-gray-400 dark:text-gray-200 group-hover:text-primary-600 group-hover:dark:text-primary-400" => $panel->getId() !== $currentPanel->getId(),
98
-                            "text-primary-600 dark:text-primary-400" => $panel->getId() === $currentPanel->getId(),
99
-                        ])
100
-                    >
101
-                        {{ $labels[$panel->getId()] ?? str($panel->getId())->ucfirst()}}
102
-                    </span>
103
-                </a>
104
-            @endforeach
105
-        </div>
106
-    </x-filament::modal>
107
-@endif

+ 3
- 4
resources/views/vendor/filament-panels/components/user-menu.blade.php View File

@@ -12,11 +12,10 @@
12 12
     $items = \Illuminate\Support\Arr::except($items, ['account', 'logout', 'profile']);
13 13
 
14 14
     $hasPanelShiftDropdown = filament()->hasPlugin('panel-shift-dropdown');
15
-    $hasTenant = filament()->getTenant() !== null;
16 15
 @endphp
17 16
 
18 17
 
19
-@if($hasTenant)
18
+@if($hasPanelShiftDropdown)
20 19
     {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_BEFORE) }}
21 20
 @endif
22 21
 
@@ -35,7 +34,7 @@
35 34
                 type="button"
36 35
                 class="shrink-0"
37 36
             >
38
-                <x-filament-panels::avatar.user :user="$user" />
37
+                <x-filament-panels::avatar.user :user="$user"/>
39 38
             </button>
40 39
         </x-slot>
41 40
 
@@ -68,7 +67,7 @@
68 67
 
69 68
         @if (filament()->hasDarkMode() && (! filament()->hasDarkModeForced()))
70 69
             <x-filament::dropdown.list>
71
-                <x-filament-panels::theme-switcher />
70
+                <x-filament-panels::theme-switcher/>
72 71
             </x-filament::dropdown.list>
73 72
         @endif
74 73
 

+ 64
- 0
tests/Feature/CompanySetupAndBehaviorTest.php View File

@@ -0,0 +1,64 @@
1
+<?php
2
+
3
+use App\Models\Accounting\Transaction;
4
+
5
+it('initially assigns a personal company to the test user', function () {
6
+    $testUser = $this->testUser;
7
+    $testCompany = $this->testCompany;
8
+
9
+    expect($testUser)->not->toBeNull()
10
+        ->and($testCompany)->not->toBeNull()
11
+        ->and($testCompany->personal_company)->toBeTrue()
12
+        ->and($testUser->currentCompany->id)->toBe($testCompany->id);
13
+});
14
+
15
+it('can create a new company and switches to it automatically', function () {
16
+    $testUser = $this->testUser;
17
+    $testCompany = $this->testCompany;
18
+
19
+    $newCompany = createCompany('New Company');
20
+
21
+    expect($newCompany)->not->toBeNull()
22
+        ->and($newCompany->name)->toBe('New Company')
23
+        ->and($newCompany->personal_company)->toBeFalse()
24
+        ->and($testUser->currentCompany->id)->toBe($newCompany->id)
25
+        ->and($newCompany->id)->not->toBe($testCompany->id);
26
+});
27
+
28
+it('returns data for the current company based on the CurrentCompanyScope', function () {
29
+    $testUser = $this->testUser;
30
+    $testCompany = $this->testCompany;
31
+
32
+    Transaction::factory()
33
+        ->forCompanyAndBankAccount($testCompany, $testCompany->default->bankAccount)
34
+        ->count(10)
35
+        ->create();
36
+
37
+    $newCompany = createCompany('New Company');
38
+
39
+    expect($testUser->currentCompany->id)
40
+        ->toBe($newCompany->id)
41
+        ->not->toBe($testCompany->id);
42
+
43
+    Transaction::factory()
44
+        ->forCompanyAndBankAccount($newCompany, $newCompany->default->bankAccount)
45
+        ->count(5)
46
+        ->create();
47
+
48
+    expect(Transaction::count())->toBe(5);
49
+
50
+    $testUser->switchCompany($testCompany);
51
+
52
+    expect($testUser->currentCompany->id)->toBe($testCompany->id)
53
+        ->and(Transaction::count())->toBe(10);
54
+});
55
+
56
+it('validates that company default settings are non-null', function () {
57
+    $testCompany = $this->testCompany;
58
+
59
+    expect($testCompany->profile->country)->not->toBeNull()
60
+        ->and($testCompany->profile->email)->not->toBeNull()
61
+        ->and($testCompany->default->currency_code)->toBe('USD')
62
+        ->and($testCompany->locale->language)->toBe('en')
63
+        ->and($testCompany->default->bankAccount->account->name)->toBe('Cash on Hand');
64
+});

+ 4
- 16
tests/Feature/ExampleTest.php View File

@@ -1,19 +1,7 @@
1 1
 <?php
2 2
 
3
-namespace Tests\Feature;
3
+it('returns a successful response', function () {
4
+    $response = $this->get('/');
4 5
 
5
-// use Illuminate\Foundation\Testing\RefreshDatabase;
6
-use Tests\TestCase;
7
-
8
-class ExampleTest extends TestCase
9
-{
10
-    /**
11
-     * A basic test example.
12
-     */
13
-    public function test_the_application_returns_a_successful_response(): void
14
-    {
15
-        $response = $this->get('/');
16
-
17
-        $response->assertStatus(200);
18
-    }
19
-}
6
+    $response->assertStatus(200);
7
+});

+ 145
- 0
tests/Feature/Reports/AccountBalancesReportTest.php View File

@@ -0,0 +1,145 @@
1
+<?php
2
+
3
+use App\Facades\Accounting;
4
+use App\Facades\Reporting;
5
+use App\Factories\ReportDateFactory;
6
+use App\Filament\Company\Pages\Reports\AccountBalances;
7
+use App\Models\Accounting\Transaction;
8
+
9
+use function Pest\Livewire\livewire;
10
+
11
+it('correctly builds an account balances report for the current fiscal year', function () {
12
+    $testCompany = $this->testCompany;
13
+
14
+    $reportDates = ReportDateFactory::create($testCompany);
15
+    $defaultDateRange = $reportDates->defaultDateRange;
16
+    $defaultStartDate = $reportDates->defaultStartDate->toImmutable();
17
+    $defaultEndDate = $reportDates->defaultEndDate->toImmutable();
18
+
19
+    $depositAmount = 1000;
20
+    $withdrawalAmount = 1000;
21
+    $depositCount = 10;
22
+    $withdrawalCount = 10;
23
+
24
+    Transaction::factory()
25
+        ->forDefaultBankAccount()
26
+        ->forUncategorizedRevenue()
27
+        ->asDeposit($depositAmount)
28
+        ->count($depositCount)
29
+        ->state([
30
+            'posted_at' => $defaultStartDate->subWeek(),
31
+        ])
32
+        ->create();
33
+
34
+    Transaction::factory()
35
+        ->forDefaultBankAccount()
36
+        ->forUncategorizedExpense()
37
+        ->asWithdrawal($withdrawalAmount)
38
+        ->count($withdrawalCount)
39
+        ->state([
40
+            'posted_at' => $defaultEndDate,
41
+        ])
42
+        ->create();
43
+
44
+    $defaultBankAccountAccount = $testCompany->default->bankAccount->account;
45
+
46
+    $expectedBalances = Accounting::getBalances(
47
+        $defaultBankAccountAccount,
48
+        $defaultStartDate->toDateString(),
49
+        $defaultEndDate->toDateString(),
50
+    );
51
+
52
+    $formattedExpectedBalances = Reporting::formatBalances($expectedBalances);
53
+
54
+    livewire(AccountBalances::class)
55
+        ->assertFormSet([
56
+            'deferredFilters.dateRange' => $defaultDateRange,
57
+            'deferredFilters.startDate' => $defaultStartDate->toDateTimeString(),
58
+            'deferredFilters.endDate' => $defaultEndDate->toDateTimeString(),
59
+        ])
60
+        ->assertSet('filters', [
61
+            'dateRange' => $defaultDateRange,
62
+            'startDate' => $defaultStartDate->toDateString(),
63
+            'endDate' => $defaultEndDate->toDateString(),
64
+        ])
65
+        ->call('applyFilters')
66
+        ->assertSeeTextInOrder([
67
+            $defaultBankAccountAccount->name,
68
+            $formattedExpectedBalances->startingBalance,
69
+            $formattedExpectedBalances->debitBalance,
70
+            $formattedExpectedBalances->creditBalance,
71
+            $formattedExpectedBalances->netMovement,
72
+            $formattedExpectedBalances->endingBalance,
73
+        ])
74
+        ->assertReportTableData();
75
+});
76
+
77
+it('correctly builds an account balances report for the previous fiscal year', function () {
78
+    $testCompany = $this->testCompany;
79
+
80
+    $reportDatesDTO = ReportDateFactory::create($testCompany);
81
+    $defaultDateRange = $reportDatesDTO->defaultDateRange;
82
+    $defaultStartDate = $reportDatesDTO->defaultStartDate->toImmutable();
83
+    $defaultEndDate = $reportDatesDTO->defaultEndDate->toImmutable();
84
+
85
+    $depositAmount = 1000;
86
+    $withdrawalAmount = 1000;
87
+    $depositCount = 10;
88
+    $withdrawalCount = 10;
89
+
90
+    Transaction::factory()
91
+        ->forDefaultBankAccount()
92
+        ->forUncategorizedRevenue()
93
+        ->asDeposit($depositAmount)
94
+        ->count($depositCount)
95
+        ->state([
96
+            'posted_at' => $defaultStartDate->subWeek(),
97
+        ])
98
+        ->create();
99
+
100
+    Transaction::factory()
101
+        ->forDefaultBankAccount()
102
+        ->forUncategorizedExpense()
103
+        ->asWithdrawal($withdrawalAmount)
104
+        ->count($withdrawalCount)
105
+        ->state([
106
+            'posted_at' => $defaultEndDate,
107
+        ])
108
+        ->create();
109
+
110
+    $defaultBankAccountAccount = $testCompany->default->bankAccount->account;
111
+
112
+    $expectedBalancesSubYear = Accounting::getBalances(
113
+        $defaultBankAccountAccount,
114
+        $defaultStartDate->subYear()->startOfYear()->toDateString(),
115
+        $defaultEndDate->subYear()->endOfYear()->toDateString(),
116
+    );
117
+
118
+    $formattedExpectedBalancesSubYear = Reporting::formatBalances($expectedBalancesSubYear);
119
+
120
+    livewire(AccountBalances::class)
121
+        ->assertFormSet([
122
+            'deferredFilters.dateRange' => $defaultDateRange,
123
+            'deferredFilters.startDate' => $defaultStartDate->toDateTimeString(),
124
+            'deferredFilters.endDate' => $defaultEndDate->toDateTimeString(),
125
+        ])
126
+        ->assertSet('filters', [
127
+            'dateRange' => $defaultDateRange,
128
+            'startDate' => $defaultStartDate->toDateString(),
129
+            'endDate' => $defaultEndDate->toDateString(),
130
+        ])
131
+        ->set('deferredFilters', [
132
+            'startDate' => $defaultStartDate->subYear()->startOfYear()->toDateTimeString(),
133
+            'endDate' => $defaultEndDate->subYear()->endOfYear()->toDateTimeString(),
134
+        ])
135
+        ->call('applyFilters')
136
+        ->assertSeeTextInOrder([
137
+            $defaultBankAccountAccount->name,
138
+            $formattedExpectedBalancesSubYear->startingBalance,
139
+            $formattedExpectedBalancesSubYear->debitBalance,
140
+            $formattedExpectedBalancesSubYear->creditBalance,
141
+            $formattedExpectedBalancesSubYear->netMovement,
142
+            $formattedExpectedBalancesSubYear->endingBalance,
143
+        ])
144
+        ->assertReportTableData();
145
+});

+ 169
- 0
tests/Feature/Reports/TrialBalanceReportTest.php View File

@@ -0,0 +1,169 @@
1
+<?php
2
+
3
+use App\Facades\Accounting;
4
+use App\Facades\Reporting;
5
+use App\Factories\ReportDateFactory;
6
+use App\Filament\Company\Pages\Reports\TrialBalance;
7
+use App\Models\Accounting\Transaction;
8
+
9
+use function Pest\Livewire\livewire;
10
+
11
+it('correctly builds a standard trial balance report', function () {
12
+    $testCompany = $this->testCompany;
13
+
14
+    $reportDates = ReportDateFactory::create($testCompany);
15
+    $defaultDateRange = $reportDates->defaultDateRange;
16
+    $defaultStartDate = $reportDates->defaultStartDate->toImmutable();
17
+    $defaultEndDate = $reportDates->defaultEndDate->toImmutable();
18
+
19
+    $defaultReportType = 'standard';
20
+
21
+    $depositAmount = 1000;
22
+    $withdrawalAmount = 1000;
23
+    $depositCount = 10;
24
+    $withdrawalCount = 10;
25
+
26
+    Transaction::factory()
27
+        ->forDefaultBankAccount()
28
+        ->forUncategorizedRevenue()
29
+        ->asDeposit($depositAmount)
30
+        ->count($depositCount)
31
+        ->state([
32
+            'posted_at' => $defaultStartDate->subWeek(),
33
+        ])
34
+        ->create();
35
+
36
+    Transaction::factory()
37
+        ->forDefaultBankAccount()
38
+        ->forUncategorizedExpense()
39
+        ->asWithdrawal($withdrawalAmount)
40
+        ->count($withdrawalCount)
41
+        ->state([
42
+            'posted_at' => now()->subWeek(),
43
+        ])
44
+        ->create();
45
+
46
+    $defaultBankAccountAccount = $testCompany->default->bankAccount->account;
47
+    $earliestTransactionDate = $reportDates->refresh()->earliestTransactionDate;
48
+
49
+    $expectedBalances = Accounting::getBalances(
50
+        $defaultBankAccountAccount,
51
+        $earliestTransactionDate->toDateString(),
52
+        $defaultEndDate->toDateString(),
53
+    );
54
+
55
+    $calculatedTrialBalances = Reporting::calculateTrialBalances($defaultBankAccountAccount->category, $expectedBalances['ending_balance']);
56
+
57
+    $formattedExpectedBalances = Reporting::formatBalances($calculatedTrialBalances);
58
+
59
+    livewire(TrialBalance::class)
60
+        ->assertFormSet([
61
+            'deferredFilters.reportType' => $defaultReportType,
62
+            'deferredFilters.dateRange' => $defaultDateRange,
63
+            'deferredFilters.asOfDate' => $defaultEndDate->toDateTimeString(),
64
+        ])
65
+        ->assertSet('filters', [
66
+            'reportType' => $defaultReportType,
67
+            'dateRange' => $defaultDateRange,
68
+            'asOfDate' => $defaultEndDate->toDateString(),
69
+        ])
70
+        ->call('applyFilters')
71
+        ->assertDontSeeText('Retained Earnings')
72
+        ->assertSeeTextInOrder([
73
+            $defaultBankAccountAccount->code,
74
+            $defaultBankAccountAccount->name,
75
+            $formattedExpectedBalances->debitBalance,
76
+            $formattedExpectedBalances->creditBalance,
77
+        ])
78
+        ->assertReportTableData();
79
+});
80
+
81
+it('correctly builds a post-closing trial balance report', function () {
82
+    $testCompany = $this->testCompany;
83
+
84
+    $reportDates = ReportDateFactory::create($testCompany);
85
+    $defaultDateRange = $reportDates->defaultDateRange;
86
+    $defaultStartDate = $reportDates->defaultStartDate->toImmutable();
87
+    $defaultEndDate = $reportDates->defaultEndDate->toImmutable();
88
+
89
+    $defaultReportType = 'postClosing';
90
+
91
+    $depositAmount = 2000;
92
+    $withdrawalAmount = 1000;
93
+    $depositCount = 5;
94
+    $withdrawalCount = 5;
95
+
96
+    Transaction::factory()
97
+        ->forDefaultBankAccount()
98
+        ->forUncategorizedRevenue()
99
+        ->asDeposit($depositAmount)
100
+        ->count($depositCount)
101
+        ->state([
102
+            'posted_at' => $defaultStartDate->subWeek(),
103
+        ])
104
+        ->create();
105
+
106
+    Transaction::factory()
107
+        ->forDefaultBankAccount()
108
+        ->forUncategorizedExpense()
109
+        ->asWithdrawal($withdrawalAmount)
110
+        ->count($withdrawalCount)
111
+        ->state([
112
+            'posted_at' => now()->subWeek(),
113
+        ])
114
+        ->create();
115
+
116
+    $defaultBankAccountAccount = $testCompany->default->bankAccount->account;
117
+    $earliestTransactionDate = $reportDates->refresh()->earliestTransactionDate;
118
+
119
+    $expectedBalances = Accounting::getBalances(
120
+        $defaultBankAccountAccount,
121
+        $earliestTransactionDate->toDateString(),
122
+        $defaultEndDate->toDateString(),
123
+    );
124
+
125
+    $calculatedTrialBalances = Reporting::calculateTrialBalances($defaultBankAccountAccount->category, $expectedBalances['ending_balance']);
126
+
127
+    $formattedExpectedBalances = Reporting::formatBalances($calculatedTrialBalances);
128
+
129
+    $formattedRetainedEarningsBalances = Reporting::getRetainedEarningsBalances($earliestTransactionDate->toDateTimeString(), $defaultEndDate->toDateTimeString());
130
+
131
+    // Use Livewire to assert the report's filters and displayed data
132
+    livewire(TrialBalance::class)
133
+        ->set('deferredFilters.reportType', $defaultReportType)
134
+        ->assertFormSet([
135
+            'deferredFilters.reportType' => $defaultReportType,
136
+            'deferredFilters.dateRange' => $defaultDateRange,
137
+            'deferredFilters.asOfDate' => $defaultEndDate->toDateTimeString(),
138
+        ])
139
+        ->call('applyFilters')
140
+        ->assertSet('filters', [
141
+            'reportType' => $defaultReportType,
142
+            'dateRange' => $defaultDateRange,
143
+            'asOfDate' => $defaultEndDate->toDateString(),
144
+        ])
145
+        ->assertSeeTextInOrder([
146
+            $defaultBankAccountAccount->code,
147
+            $defaultBankAccountAccount->name,
148
+            $formattedExpectedBalances->debitBalance,
149
+            $formattedExpectedBalances->creditBalance,
150
+        ])
151
+        ->assertSeeText('Retained Earnings')
152
+        ->assertSeeTextInOrder([
153
+            'RE',
154
+            'Retained Earnings',
155
+            $formattedRetainedEarningsBalances->debitBalance,
156
+            $formattedRetainedEarningsBalances->creditBalance,
157
+        ])
158
+        ->assertSeeTextInOrder([
159
+            'Total Revenue',
160
+            '$0.00',
161
+            '$0.00',
162
+        ])
163
+        ->assertSeeTextInOrder([
164
+            'Total Expenses',
165
+            '$0.00',
166
+            '$0.00',
167
+        ])
168
+        ->assertReportTableData();
169
+});

+ 24
- 0
tests/Helpers/helpers.php View File

@@ -0,0 +1,24 @@
1
+<?php
2
+
3
+use App\Enums\Setting\EntityType;
4
+use App\Filament\Company\Pages\CreateCompany;
5
+use App\Models\Company;
6
+
7
+use function Pest\Livewire\livewire;
8
+
9
+function createCompany(string $name): Company
10
+{
11
+    livewire(CreateCompany::class)
12
+        ->fillForm([
13
+            'name' => $name,
14
+            'profile.email' => 'company@gmail.com',
15
+            'profile.entity_type' => EntityType::LimitedLiabilityCompany,
16
+            'profile.country' => 'US',
17
+            'locale.language' => 'en',
18
+            'currencies.code' => 'USD',
19
+        ])
20
+        ->call('register')
21
+        ->assertHasNoErrors();
22
+
23
+    return auth()->user()->currentCompany;
24
+}

+ 35
- 0
tests/Pest.php View File

@@ -0,0 +1,35 @@
1
+<?php
2
+
3
+uses(Tests\TestCase::class)
4
+    ->in('Feature', 'Unit');
5
+
6
+/*
7
+|--------------------------------------------------------------------------
8
+| Expectations
9
+|--------------------------------------------------------------------------
10
+|
11
+| When you're writing tests, you often need to check that values meet certain conditions. The
12
+| "expect()" function gives you access to a set of "expectations" methods that you can use
13
+| to assert different things. Of course, you may extend the Expectation API at any time.
14
+|
15
+*/
16
+
17
+expect()->extend('toBeOne', function () {
18
+    return $this->toBe(1);
19
+});
20
+
21
+/*
22
+|--------------------------------------------------------------------------
23
+| Functions
24
+|--------------------------------------------------------------------------
25
+|
26
+| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
27
+| project that you don't want to repeat in every file. Here you can also expose helpers as
28
+| global functions to help you to reduce the number of lines of code in your test files.
29
+|
30
+*/
31
+
32
+function something()
33
+{
34
+    // ..
35
+}

+ 39
- 1
tests/TestCase.php View File

@@ -2,9 +2,47 @@
2 2
 
3 3
 namespace Tests;
4 4
 
5
+use App\Models\Company;
6
+use App\Models\User;
7
+use App\Testing\TestsReport;
8
+use Database\Seeders\TestDatabaseSeeder;
9
+use Filament\Facades\Filament;
10
+use Illuminate\Foundation\Testing\RefreshDatabase;
5 11
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
12
+use Livewire\Features\SupportTesting\Testable;
6 13
 
7 14
 abstract class TestCase extends BaseTestCase
8 15
 {
9
-    //
16
+    use RefreshDatabase;
17
+
18
+    /**
19
+     * Indicates whether the default seeder should run before each test.
20
+     */
21
+    protected bool $seed = true;
22
+
23
+    /**
24
+     * Run a specific seeder before each test.
25
+     */
26
+    protected string $seeder = TestDatabaseSeeder::class;
27
+
28
+    protected User $testUser;
29
+
30
+    protected ?Company $testCompany;
31
+
32
+    protected function setUp(): void
33
+    {
34
+        parent::setUp();
35
+
36
+        Testable::mixin(new TestsReport);
37
+
38
+        $this->testUser = User::first();
39
+
40
+        $this->testCompany = $this->testUser->ownedCompanies->first();
41
+
42
+        $this->testUser->switchCompany($this->testCompany);
43
+
44
+        $this->actingAs($this->testUser);
45
+
46
+        Filament::setTenant($this->testCompany);
47
+    }
10 48
 }

+ 3
- 14
tests/Unit/ExampleTest.php View File

@@ -1,16 +1,5 @@
1 1
 <?php
2 2
 
3
-namespace Tests\Unit;
4
-
5
-use PHPUnit\Framework\TestCase;
6
-
7
-class ExampleTest extends TestCase
8
-{
9
-    /**
10
-     * A basic test example.
11
-     */
12
-    public function test_that_true_is_true(): void
13
-    {
14
-        $this->assertTrue(true);
15
-    }
16
-}
3
+test('true is true', function () {
4
+    expect(true)->toBeTrue();
5
+});

Loading…
Cancel
Save