Bladeren bron

wip add aging reports

3.x
Andrew Wallo 8 maanden geleden
bovenliggende
commit
ecc6b6de98

+ 10
- 6
README.md Bestand weergeven

@@ -15,7 +15,7 @@
15 15
 
16 16
 
17 17
 
18
-This repo is currently a work in progress — PRs and issues welcome!
18
+A Laravel and Filament-powered accounting platform, crafting a modern and automated solution for financial management.
19 19
 
20 20
 # Getting started
21 21
 
@@ -203,7 +203,8 @@ public static function getAllLanguages(): array
203 203
 
204 204
 ## Plaid Integration
205 205
 
206
-To integrate [Plaid](https://plaid.com/) with your application for enhanced financial data connectivity, you must first create an account with Plaid and obtain your credentials. Set your credentials in the `.env` file as follows:
206
+To integrate [Plaid](https://plaid.com/) with your application for enhanced financial data connectivity, you must first
207
+create an account with Plaid and obtain your credentials. Set your credentials in the `.env` file as follows:
207 208
 
208 209
 ```env
209 210
 PLAID_CLIENT_ID=your-client-id
@@ -212,9 +213,12 @@ PLAID_ENVIRONMENT=sandbox # Can be sandbox, development, or production
212 213
 PLAID_WEBHOOK_URL=https://my-static-domain.ngrok-free.app/api/plaid/webhook # Must have /api/plaid/webhook appended
213 214
 ```
214 215
 
215
-The `PLAID_WEBHOOK_URL` is essential as it enables your application to receive real-time updates on transactions from connected bank accounts. This webhook URL must contain a static domain, which can be obtained from services like ngrok that offer a free static domain upon signup. Alternatively, you may use any other service that provides a static domain.
216
+The `PLAID_WEBHOOK_URL` is essential as it enables your application to receive real-time updates on transactions from
217
+connected bank accounts. This webhook URL must contain a static domain, which can be obtained from services like ngrok
218
+that offer a free static domain upon signup. Alternatively, you may use any other service that provides a static domain.
216 219
 
217
-After integrating Plaid, you can connect your account on the "Connected Accounts" page and link your financial institution. Before importing transactions, ensure to run the following command to process the queued transactions:
220
+After integrating Plaid, you can connect your account on the "Connected Accounts" page and link your financial
221
+institution. Before importing transactions, ensure to run the following command to process the queued transactions:
218 222
 
219 223
 ```bash
220 224
 php artisan queue:work --queue=transactions
@@ -243,7 +247,6 @@ The testing process automatically handles refreshing and seeding the test databa
243 247
 migration is required. For more information on how to write and run tests using
244 248
 Pest, refer to the official documentation: [Pest Documentation](https://pestphp.com/docs).
245 249
 
246
-
247 250
 ## Dependencies
248 251
 
249 252
 - [filamentphp/filament](https://github.com/filamentphp/filament) - A collection of beautiful full-stack components
@@ -254,7 +257,8 @@ Pest, refer to the official documentation: [Pest Documentation](https://pestphp.
254 257
 - [akaunting/laravel-money](https://github.com/akaunting/laravel-money) - Currency formatting and conversion package for
255 258
   Laravel
256 259
 - [squirephp/squire](https://github.com/squirephp/squire) - A library of static Eloquent models for common fixture data
257
-- [awcodes/filament-table-repeater](https://github.com/awcodes/filament-table-repeater) - A modified version of the Filament Forms Repeater to display it as a table. 
260
+- [awcodes/filament-table-repeater](https://github.com/awcodes/filament-table-repeater) - A modified version of the
261
+  Filament Forms Repeater to display it as a table.
258 262
 
259 263
 ***Note*** : It is recommended to read the documentation for all dependencies to get yourself familiar with how the
260 264
 application works.

+ 8
- 0
app/Contracts/MoneyFormattableDTO.php Bestand weergeven

@@ -0,0 +1,8 @@
1
+<?php
2
+
3
+namespace App\Contracts;
4
+
5
+interface MoneyFormattableDTO
6
+{
7
+    public static function fromArray(array $balances): static;
8
+}

+ 14
- 1
app/DTO/AccountBalanceDTO.php Bestand weergeven

@@ -2,7 +2,9 @@
2 2
 
3 3
 namespace App\DTO;
4 4
 
5
-class AccountBalanceDTO
5
+use App\Contracts\MoneyFormattableDTO;
6
+
7
+class AccountBalanceDTO implements MoneyFormattableDTO
6 8
 {
7 9
     public function __construct(
8 10
         public ?string $startingBalance,
@@ -11,4 +13,15 @@ class AccountBalanceDTO
11 13
         public ?string $netMovement,
12 14
         public ?string $endingBalance,
13 15
     ) {}
16
+
17
+    public static function fromArray(array $balances): static
18
+    {
19
+        return new static(
20
+            startingBalance: $balances['starting_balance'] ?? null,
21
+            debitBalance: $balances['debit_balance'] ?? null,
22
+            creditBalance: $balances['credit_balance'] ?? null,
23
+            netMovement: $balances['net_movement'] ?? null,
24
+            endingBalance: $balances['ending_balance'] ?? null,
25
+        );
26
+    }
14 27
 }

+ 38
- 0
app/DTO/AgingBucketDTO.php Bestand weergeven

@@ -0,0 +1,38 @@
1
+<?php
2
+
3
+namespace App\DTO;
4
+
5
+use App\Contracts\MoneyFormattableDTO;
6
+
7
+readonly class AgingBucketDTO implements MoneyFormattableDTO
8
+{
9
+    /**
10
+     * @param  array<string, string>  $periods
11
+     */
12
+    public function __construct(
13
+        public string $current,
14
+        public array $periods,
15
+        public string $overPeriods,
16
+        public string $total,
17
+    ) {}
18
+
19
+    public static function fromArray(array $balances): static
20
+    {
21
+        $periods = [];
22
+
23
+        // Extract all period balances
24
+        foreach ($balances as $key => $value) {
25
+            if (str_starts_with($key, 'period_')) {
26
+                $periods[$key] = $value;
27
+                unset($balances[$key]);
28
+            }
29
+        }
30
+
31
+        return new static(
32
+            current: $balances['current'],
33
+            periods: $periods,
34
+            overPeriods: $balances['over_periods'],
35
+            total: $balances['total'],
36
+        );
37
+    }
38
+}

+ 12
- 0
app/DTO/ClientReportDTO.php Bestand weergeven

@@ -0,0 +1,12 @@
1
+<?php
2
+
3
+namespace App\DTO;
4
+
5
+readonly class ClientReportDTO
6
+{
7
+    public function __construct(
8
+        public string $clientName,
9
+        public string $clientId,
10
+        public AgingBucketDTO $aging,
11
+    ) {}
12
+}

+ 12
- 0
app/DTO/EntityReportDTO.php Bestand weergeven

@@ -0,0 +1,12 @@
1
+<?php
2
+
3
+namespace App\DTO;
4
+
5
+readonly class EntityReportDTO
6
+{
7
+    public function __construct(
8
+        public string $name,
9
+        public string $id,
10
+        public AgingBucketDTO $aging,
11
+    ) {}
12
+}

+ 1
- 0
app/DTO/ReportDTO.php Bestand weergeven

@@ -12,6 +12,7 @@ class ReportDTO
12 12
          */
13 13
         public array $categories,
14 14
         public ?AccountBalanceDTO $overallTotal = null,
15
+        public ?AgingBucketDTO $agingSummary = null,
15 16
         public array $fields = [],
16 17
         public ?string $reportType = null,
17 18
         public ?CashFlowOverviewDTO $overview = null,

+ 12
- 0
app/DTO/VendorReportDTO.php Bestand weergeven

@@ -0,0 +1,12 @@
1
+<?php
2
+
3
+namespace App\DTO;
4
+
5
+readonly class VendorReportDTO
6
+{
7
+    public function __construct(
8
+        public string $vendorName,
9
+        public string $vendorId,
10
+        public AgingBucketDTO $aging,
11
+    ) {}
12
+}

+ 24
- 0
app/Enums/Accounting/DocumentEntityType.php Bestand weergeven

@@ -0,0 +1,24 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+
7
+enum DocumentEntityType: string implements HasLabel
8
+{
9
+    case Client = 'client';
10
+    case Vendor = 'vendor';
11
+
12
+    public function getLabel(): ?string
13
+    {
14
+        return $this->name;
15
+    }
16
+
17
+    public function getReportTitle(): string
18
+    {
19
+        return match ($this) {
20
+            self::Client => 'Accounts Receivable Aging',
21
+            self::Vendor => 'Accounts Payable Aging',
22
+        };
23
+    }
24
+}

+ 48
- 6
app/Filament/Company/Pages/Reports.php Bestand weergeven

@@ -3,6 +3,8 @@
3 3
 namespace App\Filament\Company\Pages;
4 4
 
5 5
 use App\Filament\Company\Pages\Reports\AccountBalances;
6
+use App\Filament\Company\Pages\Reports\AccountsPayableAging;
7
+use App\Filament\Company\Pages\Reports\AccountsReceivableAging;
6 8
 use App\Filament\Company\Pages\Reports\AccountTransactions;
7 9
 use App\Filament\Company\Pages\Reports\BalanceSheet;
8 10
 use App\Filament\Company\Pages\Reports\CashFlowStatement;
@@ -55,14 +57,14 @@ class Reports extends Page
55 57
                             ->heading('Income Statement')
56 58
                             ->description('Shows revenue, expenses, and net earnings over a period, indicating overall financial performance.')
57 59
                             ->icon('heroicon-o-chart-bar')
58
-                            ->iconColor(Color::Indigo)
60
+                            ->iconColor(Color::Purple)
59 61
                             ->url(IncomeStatement::getUrl()),
60 62
                         ReportEntry::make('balance_sheet')
61 63
                             ->hiddenLabel()
62 64
                             ->heading('Balance Sheet')
63 65
                             ->description('Displays your company’s assets, liabilities, and equity at a single point in time, showing overall financial health and stability.')
64 66
                             ->icon('heroicon-o-clipboard-document-list')
65
-                            ->iconColor(Color::Emerald)
67
+                            ->iconColor(Color::Teal)
66 68
                             ->url(BalanceSheet::getUrl()),
67 69
                         ReportEntry::make('cash_flow_statement')
68 70
                             ->hiddenLabel()
@@ -72,6 +74,46 @@ class Reports extends Page
72 74
                             ->iconColor(Color::Cyan)
73 75
                             ->url(CashFlowStatement::getUrl()),
74 76
                     ]),
77
+                Section::make('Customer Reports')
78
+                    ->aside()
79
+                    ->description('Reports that provide detailed information on your company’s customer transactions and balances.')
80
+                    ->extraAttributes(['class' => 'es-report-card'])
81
+                    ->schema([
82
+                        ReportEntry::make('ar_aging')
83
+                            ->hiddenLabel()
84
+                            ->heading('Accounts Receivable Aging')
85
+                            ->description('Lists outstanding receivables by customer, showing how long invoices have been unpaid.')
86
+                            ->icon('heroicon-o-calendar-date-range')
87
+                            ->iconColor(Color::Indigo)
88
+                            ->url(AccountsReceivableAging::getUrl()),
89
+                        ReportEntry::make('income_by_customer')
90
+                            ->hiddenLabel()
91
+                            ->heading('Income by Customer')
92
+                            ->description('Shows revenue generated by each customer, helping identify top customers and opportunities for growth.')
93
+                            ->icon('heroicon-o-arrow-trending-up')
94
+                            ->iconColor(Color::Emerald)
95
+                            ->url('https://example.com'),
96
+                    ]),
97
+                Section::make('Vendor Reports')
98
+                    ->aside()
99
+                    ->description('Reports that provide detailed information on your company’s vendor transactions and balances.')
100
+                    ->extraAttributes(['class' => 'es-report-card'])
101
+                    ->schema([
102
+                        ReportEntry::make('ap_aging')
103
+                            ->hiddenLabel()
104
+                            ->heading('Accounts Payable Aging')
105
+                            ->description('Lists outstanding payables by vendor, showing how long invoices have been unpaid.')
106
+                            ->icon('heroicon-o-clock')
107
+                            ->iconColor(Color::Rose)
108
+                            ->url(AccountsPayableAging::getUrl()),
109
+                        ReportEntry::make('expenses_by_vendor')
110
+                            ->hiddenLabel()
111
+                            ->heading('Expenses by Vendor')
112
+                            ->description('Shows expenses incurred with each vendor, helping identify top vendors and opportunities for cost savings.')
113
+                            ->icon('heroicon-o-arrow-trending-down')
114
+                            ->iconColor(Color::Orange)
115
+                            ->url('https://example.com'),
116
+                    ]),
75 117
                 Section::make('Detailed Reports')
76 118
                     ->aside()
77 119
                     ->description('Detailed reports that provide a comprehensive view of your company’s financial transactions and account balances.')
@@ -81,8 +123,8 @@ class Reports extends Page
81 123
                             ->hiddenLabel()
82 124
                             ->heading('Account Balances')
83 125
                             ->description('Lists all accounts and their balances, including starting, debit, credit, net movement, and ending balances.')
84
-                            ->icon('heroicon-o-currency-dollar')
85
-                            ->iconColor(Color::Teal)
126
+                            ->icon('heroicon-o-calculator')
127
+                            ->iconColor(Color::Slate)
86 128
                             ->url(AccountBalances::getUrl()),
87 129
                         ReportEntry::make('trial_balance')
88 130
                             ->hiddenLabel()
@@ -95,8 +137,8 @@ class Reports extends Page
95 137
                             ->hiddenLabel()
96 138
                             ->heading('Account Transactions')
97 139
                             ->description('A record of all transactions, essential for monitoring and reconciling financial activity in the ledger.')
98
-                            ->icon('heroicon-o-adjustments-horizontal')
99
-                            ->iconColor(Color::Amber)
140
+                            ->icon('heroicon-o-list-bullet')
141
+                            ->iconColor(Color::Yellow)
100 142
                             ->url(AccountTransactions::getUrl()),
101 143
                     ]),
102 144
             ]);

+ 13
- 0
app/Filament/Company/Pages/Reports/AccountsPayableAging.php Bestand weergeven

@@ -0,0 +1,13 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Pages\Reports;
4
+
5
+use App\Enums\Accounting\DocumentEntityType;
6
+
7
+class AccountsPayableAging extends BaseAgingReportPage
8
+{
9
+    protected function getEntityType(): DocumentEntityType
10
+    {
11
+        return DocumentEntityType::Vendor;
12
+    }
13
+}

+ 13
- 0
app/Filament/Company/Pages/Reports/AccountsReceivableAging.php Bestand weergeven

@@ -0,0 +1,13 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Pages\Reports;
4
+
5
+use App\Enums\Accounting\DocumentEntityType;
6
+
7
+class AccountsReceivableAging extends BaseAgingReportPage
8
+{
9
+    protected function getEntityType(): DocumentEntityType
10
+    {
11
+        return DocumentEntityType::Client;
12
+    }
13
+}

+ 138
- 0
app/Filament/Company/Pages/Reports/BaseAgingReportPage.php Bestand weergeven

@@ -0,0 +1,138 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Pages\Reports;
4
+
5
+use App\Contracts\ExportableReport;
6
+use App\DTO\ReportDTO;
7
+use App\Enums\Accounting\DocumentEntityType;
8
+use App\Filament\Forms\Components\DateRangeSelect;
9
+use App\Services\ExportService;
10
+use App\Services\ReportService;
11
+use App\Support\Column;
12
+use App\Transformers\AgingReportTransformer;
13
+use Filament\Forms\Components\TextInput;
14
+use Filament\Forms\Form;
15
+use Filament\Support\Enums\Alignment;
16
+use Filament\Support\RawJs;
17
+use Symfony\Component\HttpFoundation\StreamedResponse;
18
+
19
+abstract class BaseAgingReportPage extends BaseReportPage
20
+{
21
+    protected static string $view = 'filament.company.pages.reports.trial-balance';
22
+
23
+    protected ReportService $reportService;
24
+
25
+    protected ExportService $exportService;
26
+
27
+    abstract protected function getEntityType(): DocumentEntityType;
28
+
29
+    public function boot(ReportService $reportService, ExportService $exportService): void
30
+    {
31
+        $this->reportService = $reportService;
32
+        $this->exportService = $exportService;
33
+    }
34
+
35
+    protected function initializeDefaultFilters(): void
36
+    {
37
+        if (empty($this->getFilterState('days_per_period'))) {
38
+            $this->setFilterState('days_per_period', 30);
39
+        }
40
+
41
+        if (empty($this->getFilterState('number_of_periods'))) {
42
+            $this->setFilterState('number_of_periods', 4);
43
+        }
44
+    }
45
+
46
+    public function getTable(): array
47
+    {
48
+        $daysPerPeriod = $this->getFilterState('days_per_period');
49
+        $numberOfPeriods = $this->getFilterState('number_of_periods');
50
+
51
+        $columns = [
52
+            Column::make('entity_name')
53
+                ->label($this->getEntityType()->getLabel())
54
+                ->alignment(Alignment::Left),
55
+            Column::make('current')
56
+                ->label('Current')
57
+                ->alignment(Alignment::Right),
58
+        ];
59
+
60
+        for ($i = 1; $i < $numberOfPeriods; $i++) {
61
+            $start = ($i - 1) * $daysPerPeriod + 1;
62
+            $end = $i * $daysPerPeriod;
63
+
64
+            $columns[] = Column::make("period_{$i}")
65
+                ->label("{$start} to {$end}")
66
+                ->alignment(Alignment::Right);
67
+        }
68
+
69
+        $columns[] = Column::make('over_periods')
70
+            ->label('Over ' . (($numberOfPeriods - 1) * $daysPerPeriod))
71
+            ->alignment(Alignment::Right);
72
+
73
+        $columns[] = Column::make('total')
74
+            ->label('Total')
75
+            ->alignment(Alignment::Right);
76
+
77
+        return $columns;
78
+    }
79
+
80
+    public function filtersForm(Form $form): Form
81
+    {
82
+        return $form
83
+            ->columns(4)
84
+            ->schema([
85
+                DateRangeSelect::make('dateRange')
86
+                    ->label('As of')
87
+                    ->selectablePlaceholder(false)
88
+                    ->endDateField('asOfDate'),
89
+                $this->getAsOfDateFormComponent(),
90
+                TextInput::make('days_per_period')
91
+                    ->label('Days per period')
92
+                    ->integer()
93
+                    ->mask(RawJs::make(<<<'JS'
94
+                        $input > 365 ? '365' : '999'
95
+                    JS)),
96
+                TextInput::make('number_of_periods')
97
+                    ->label('Number of periods')
98
+                    ->integer()
99
+                    ->mask(RawJs::make(<<<'JS'
100
+                        $input > 10 ? '10' : '99'
101
+                    JS)),
102
+            ]);
103
+    }
104
+
105
+    protected function buildReport(array $columns): ReportDTO
106
+    {
107
+        return $this->reportService->buildAgingReport(
108
+            $this->getFormattedAsOfDate(),
109
+            $this->getEntityType(),
110
+            $columns,
111
+            $this->getFilterState('days_per_period'),
112
+            $this->getFilterState('number_of_periods')
113
+        );
114
+    }
115
+
116
+    protected function getTransformer(ReportDTO $reportDTO): ExportableReport
117
+    {
118
+        return new AgingReportTransformer($reportDTO, $this->getEntityType());
119
+    }
120
+
121
+    public function exportCSV(): StreamedResponse
122
+    {
123
+        return $this->exportService->exportToCsv(
124
+            $this->company,
125
+            $this->report,
126
+            endDate: $this->getFilterState('asOfDate')
127
+        );
128
+    }
129
+
130
+    public function exportPDF(): StreamedResponse
131
+    {
132
+        return $this->exportService->exportToPdf(
133
+            $this->company,
134
+            $this->report,
135
+            endDate: $this->getFilterState('asOfDate')
136
+        );
137
+    }
138
+}

+ 34
- 0
app/Services/AccountService.php Bestand weergeven

@@ -370,4 +370,38 @@ class AccountService
370 370
 
371 371
         return $earliestDate ?? today()->toDateTimeString();
372 372
     }
373
+
374
+    public function getUnpaidClientInvoices(?string $asOfDate = null): Builder
375
+    {
376
+        $asOfDate = $asOfDate ?? now()->toDateString();
377
+
378
+        return Invoice::query()
379
+            ->select([
380
+                'invoices.id',
381
+                'invoices.client_id',
382
+                'invoices.due_date',
383
+                'invoices.amount_due',
384
+                DB::raw('DATEDIFF(?, invoices.due_date) as days_overdue'),
385
+            ])
386
+            ->addBinding([$asOfDate], 'select')
387
+            ->unpaid()
388
+            ->where('amount_due', '>', 0);
389
+    }
390
+
391
+    public function getUnpaidVendorBills(?string $asOfDate = null): Builder
392
+    {
393
+        $asOfDate = $asOfDate ?? now()->toDateString();
394
+
395
+        return Bill::query()
396
+            ->select([
397
+                'bills.id',
398
+                'bills.vendor_id',
399
+                'bills.due_date',
400
+                'bills.amount_due',
401
+                DB::raw('DATEDIFF(?, bills.due_date) as days_overdue'),
402
+            ])
403
+            ->addBinding([$asOfDate], 'select')
404
+            ->outstanding()
405
+            ->where('amount_due', '>', 0);
406
+    }
373 407
 }

+ 8
- 6
app/Services/ExportService.php Bestand weergeven

@@ -221,14 +221,16 @@ class ExportService
221 221
      * @throws CannotInsertRecord
222 222
      * @throws Exception
223 223
      */
224
-    protected function writeDataRowsToCsv(Writer $csv, array $header, array $data, array $columns): void
224
+    protected function writeDataRowsToCsv(Writer $csv, ?array $header, array $data, array $columns): void
225 225
     {
226
-        if (isset($header[0]) && is_array($header[0])) {
227
-            foreach ($header as $headerRow) {
228
-                $csv->insertOne($headerRow);
226
+        if ($header) {
227
+            if (isset($header[0]) && is_array($header[0])) {
228
+                foreach ($header as $headerRow) {
229
+                    $csv->insertOne($headerRow);
230
+                }
231
+            } else {
232
+                $csv->insertOne($header);
229 233
             }
230
-        } else {
231
-            $csv->insertOne($header);
232 234
         }
233 235
 
234 236
         // Output data rows

+ 94
- 14
app/Services/ReportService.php Bestand weergeven

@@ -2,15 +2,19 @@
2 2
 
3 3
 namespace App\Services;
4 4
 
5
+use App\Contracts\MoneyFormattableDTO;
5 6
 use App\DTO\AccountBalanceDTO;
6 7
 use App\DTO\AccountCategoryDTO;
7 8
 use App\DTO\AccountDTO;
8 9
 use App\DTO\AccountTransactionDTO;
9 10
 use App\DTO\AccountTypeDTO;
11
+use App\DTO\AgingBucketDTO;
10 12
 use App\DTO\CashFlowOverviewDTO;
13
+use App\DTO\EntityReportDTO;
11 14
 use App\DTO\ReportDTO;
12 15
 use App\Enums\Accounting\AccountCategory;
13 16
 use App\Enums\Accounting\AccountType;
17
+use App\Enums\Accounting\DocumentEntityType;
14 18
 use App\Enums\Accounting\TransactionType;
15 19
 use App\Models\Accounting\Account;
16 20
 use App\Models\Accounting\Transaction;
@@ -27,21 +31,26 @@ class ReportService
27 31
         protected AccountService $accountService,
28 32
     ) {}
29 33
 
30
-    public function formatBalances(array $balances): AccountBalanceDTO
34
+    /**
35
+     * @param  class-string<MoneyFormattableDTO>|null  $dtoClass
36
+     */
37
+    public function formatBalances(array $balances, ?string $dtoClass = null, bool $formatZeros = true): MoneyFormattableDTO | array
31 38
     {
32
-        $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
39
+        $dtoClass ??= AccountBalanceDTO::class;
40
+
41
+        $formattedBalances = array_map(static function ($balance) use ($formatZeros) {
42
+            if (! $formatZeros && $balance === 0) {
43
+                return '';
44
+            }
33 45
 
34
-        foreach ($balances as $key => $balance) {
35
-            $balances[$key] = money($balance, $defaultCurrency)->format();
46
+            return CurrencyConverter::formatCentsToMoney($balance);
47
+        }, $balances);
48
+
49
+        if (! $dtoClass) {
50
+            return $formattedBalances;
36 51
         }
37 52
 
38
-        return new AccountBalanceDTO(
39
-            startingBalance: $balances['starting_balance'] ?? null,
40
-            debitBalance: $balances['debit_balance'] ?? null,
41
-            creditBalance: $balances['credit_balance'] ?? null,
42
-            netMovement: $balances['net_movement'] ?? null,
43
-            endingBalance: $balances['ending_balance'] ?? null,
44
-        );
53
+        return $dtoClass::fromArray($formattedBalances);
45 54
     }
46 55
 
47 56
     public function buildAccountBalanceReport(string $startDate, string $endDate, array $columns = []): ReportDTO
@@ -361,10 +370,10 @@ class ReportService
361 370
 
362 371
         $formattedReportTotalBalances = $this->formatBalances($reportTotalBalances);
363 372
 
364
-        return new ReportDTO($accountCategories, $formattedReportTotalBalances, $columns, $trialBalanceType);
373
+        return new ReportDTO(categories: $accountCategories, overallTotal: $formattedReportTotalBalances, fields: $columns, reportType: $trialBalanceType);
365 374
     }
366 375
 
367
-    public function getRetainedEarningsBalances(string $startDate, string $endDate): AccountBalanceDTO
376
+    public function getRetainedEarningsBalances(string $startDate, string $endDate): MoneyFormattableDTO | array
368 377
     {
369 378
         $retainedEarningsAmount = $this->calculateRetainedEarnings($startDate, $endDate)->getAmount();
370 379
 
@@ -507,7 +516,7 @@ class ReportService
507 516
         );
508 517
     }
509 518
 
510
-    private function calculateTotalCashFlows(array $sections, string $startDate): AccountBalanceDTO
519
+    private function calculateTotalCashFlows(array $sections, string $startDate): MoneyFormattableDTO | array
511 520
     {
512 521
         $totalInflow = 0;
513 522
         $totalOutflow = 0;
@@ -819,4 +828,75 @@ class ReportService
819 828
             endDate: $asOfDateCarbon,
820 829
         );
821 830
     }
831
+
832
+    public function buildAgingReport(
833
+        string $asOfDate,
834
+        DocumentEntityType $entityType,
835
+        array $columns = [],
836
+        int $daysPerPeriod = 30,
837
+        int $numberOfPeriods = 4
838
+    ): ReportDTO {
839
+        $asOfDateCarbon = Carbon::parse($asOfDate);
840
+
841
+        $documents = $entityType === DocumentEntityType::Client
842
+            ? $this->accountService->getUnpaidClientInvoices($asOfDate)->with(['client:id,name'])->get()->groupBy('client_id')
843
+            : $this->accountService->getUnpaidVendorBills($asOfDate)->with(['vendor:id,name'])->get()->groupBy('vendor_id');
844
+
845
+        $categories = [];
846
+
847
+        $agingBuckets = ['current' => 0];
848
+
849
+        for ($i = 1; $i <= $numberOfPeriods; $i++) {
850
+            $agingBuckets["period_{$i}"] = 0;
851
+        }
852
+
853
+        $agingBuckets['over_periods'] = 0;
854
+        $agingBuckets['total'] = 0;
855
+
856
+        $totalAging = $agingBuckets;
857
+
858
+        foreach ($documents as $entityId => $entityDocuments) {
859
+            $aging = $agingBuckets;
860
+
861
+            foreach ($entityDocuments as $document) {
862
+                $daysOverdue = $document->days_overdue ?? 0;
863
+                $balance = $document->getRawOriginal('amount_due');
864
+
865
+                if ($daysOverdue <= 0) {
866
+                    $aging['current'] += $balance;
867
+                } else {
868
+                    $period = ceil($daysOverdue / $daysPerPeriod);
869
+
870
+                    if ($period <= $numberOfPeriods) {
871
+                        $aging["period_{$period}"] += $balance;
872
+                    } else {
873
+                        $aging['over_periods'] += $balance;
874
+                    }
875
+                }
876
+            }
877
+
878
+            $aging['total'] = array_sum($aging);
879
+
880
+            foreach ($aging as $bucket => $amount) {
881
+                $totalAging[$bucket] += $amount;
882
+            }
883
+
884
+            $entity = $entityDocuments->first()->{$entityType->value};
885
+
886
+            $categories[] = new EntityReportDTO(
887
+                name: $entity->name,
888
+                id: $entityId,
889
+                aging: $this->formatBalances($aging, AgingBucketDTO::class, false),
890
+            );
891
+        }
892
+
893
+        $totalAging['total'] = array_sum($totalAging);
894
+
895
+        return new ReportDTO(
896
+            categories: ['Entities' => $categories],
897
+            agingSummary: $this->formatBalances($totalAging, AgingBucketDTO::class),
898
+            fields: $columns,
899
+            endDate: $asOfDateCarbon,
900
+        );
901
+    }
822 902
 }

+ 74
- 0
app/Transformers/AccountsPayableAgingTransformer.php Bestand weergeven

@@ -0,0 +1,74 @@
1
+<?php
2
+
3
+namespace App\Transformers;
4
+
5
+use App\DTO\ReportCategoryDTO;
6
+use App\DTO\VendorReportDTO;
7
+
8
+class AccountsPayableAgingTransformer extends BaseReportTransformer
9
+{
10
+    public function getTitle(): string
11
+    {
12
+        return 'Accounts Payable Aging';
13
+    }
14
+
15
+    /**
16
+     * @return ReportCategoryDTO[]
17
+     */
18
+    public function getCategories(): array
19
+    {
20
+        $categories = [];
21
+
22
+        foreach ($this->report->categories as $accountCategory) {
23
+            $data = array_map(function (VendorReportDTO $vendor) {
24
+                $row = [];
25
+
26
+                foreach ($this->getColumns() as $column) {
27
+                    $columnName = $column->getName();
28
+
29
+                    $row[$columnName] = match (true) {
30
+                        $columnName === 'vendor_name' => [
31
+                            'name' => $vendor->vendorName,
32
+                            'id' => $vendor->vendorId,
33
+                        ],
34
+                        $columnName === 'current' => $vendor->aging->current,
35
+                        str_starts_with($columnName, 'period_') => $vendor->aging->periods[$columnName] ?? null,
36
+                        $columnName === 'over_periods' => $vendor->aging->overPeriods,
37
+                        $columnName === 'total' => $vendor->aging->total,
38
+                        default => '',
39
+                    };
40
+                }
41
+
42
+                return $row;
43
+            }, $accountCategory);
44
+
45
+            $categories[] = new ReportCategoryDTO(
46
+                header: null,
47
+                data: $data,
48
+                summary: null,
49
+            );
50
+        }
51
+
52
+        return $categories;
53
+    }
54
+
55
+    public function getOverallTotals(): array
56
+    {
57
+        $totals = [];
58
+
59
+        foreach ($this->getColumns() as $column) {
60
+            $columnName = $column->getName();
61
+
62
+            $totals[$columnName] = match (true) {
63
+                $columnName === 'vendor_name' => 'Total',
64
+                $columnName === 'current' => $this->report->agingSummary->current,
65
+                str_starts_with($columnName, 'period_') => $this->report->agingSummary->periods[$columnName] ?? null,
66
+                $columnName === 'over_periods' => $this->report->agingSummary->overPeriods,
67
+                $columnName === 'total' => $this->report->agingSummary->total,
68
+                default => '',
69
+            };
70
+        }
71
+
72
+        return $totals;
73
+    }
74
+}

+ 74
- 0
app/Transformers/AccountsReceivableAgingTransformer.php Bestand weergeven

@@ -0,0 +1,74 @@
1
+<?php
2
+
3
+namespace App\Transformers;
4
+
5
+use App\DTO\ClientReportDTO;
6
+use App\DTO\ReportCategoryDTO;
7
+
8
+class AccountsReceivableAgingTransformer extends BaseReportTransformer
9
+{
10
+    public function getTitle(): string
11
+    {
12
+        return 'Accounts Receivable Aging';
13
+    }
14
+
15
+    /**
16
+     * @return ReportCategoryDTO[]
17
+     */
18
+    public function getCategories(): array
19
+    {
20
+        $categories = [];
21
+
22
+        foreach ($this->report->categories as $accountCategory) {
23
+            $data = array_map(function (ClientReportDTO $client) {
24
+                $row = [];
25
+
26
+                foreach ($this->getColumns() as $column) {
27
+                    $columnName = $column->getName();
28
+
29
+                    $row[$columnName] = match (true) {
30
+                        $columnName === 'client_name' => [
31
+                            'name' => $client->clientName,
32
+                            'id' => $client->clientId,
33
+                        ],
34
+                        $columnName === 'current' => $client->aging->current,
35
+                        str_starts_with($columnName, 'period_') => $client->aging->periods[$columnName] ?? null,
36
+                        $columnName === 'over_periods' => $client->aging->overPeriods,
37
+                        $columnName === 'total' => $client->aging->total,
38
+                        default => '',
39
+                    };
40
+                }
41
+
42
+                return $row;
43
+            }, $accountCategory);
44
+
45
+            $categories[] = new ReportCategoryDTO(
46
+                header: null,
47
+                data: $data,
48
+                summary: null,
49
+            );
50
+        }
51
+
52
+        return $categories;
53
+    }
54
+
55
+    public function getOverallTotals(): array
56
+    {
57
+        $totals = [];
58
+
59
+        foreach ($this->getColumns() as $column) {
60
+            $columnName = $column->getName();
61
+
62
+            $totals[$columnName] = match (true) {
63
+                $columnName === 'client_name' => 'Total',
64
+                $columnName === 'current' => $this->report->agingSummary->current,
65
+                str_starts_with($columnName, 'period_') => $this->report->agingSummary->periods[$columnName] ?? null,
66
+                $columnName === 'over_periods' => $this->report->agingSummary->overPeriods,
67
+                $columnName === 'total' => $this->report->agingSummary->total,
68
+                default => '',
69
+            };
70
+        }
71
+
72
+        return $totals;
73
+    }
74
+}

+ 85
- 0
app/Transformers/AgingReportTransformer.php Bestand weergeven

@@ -0,0 +1,85 @@
1
+<?php
2
+
3
+namespace App\Transformers;
4
+
5
+use App\DTO\EntityReportDTO;
6
+use App\DTO\ReportCategoryDTO;
7
+use App\DTO\ReportDTO;
8
+use App\Enums\Accounting\DocumentEntityType;
9
+
10
+class AgingReportTransformer extends BaseReportTransformer
11
+{
12
+    public function __construct(
13
+        ReportDTO $report,
14
+        private readonly DocumentEntityType $entityType,
15
+    ) {
16
+        parent::__construct($report);
17
+    }
18
+
19
+    public function getTitle(): string
20
+    {
21
+        return $this->entityType->getReportTitle();
22
+    }
23
+
24
+    /**
25
+     * @return ReportCategoryDTO[]
26
+     */
27
+    public function getCategories(): array
28
+    {
29
+        $categories = [];
30
+
31
+        foreach ($this->report->categories as $category) {
32
+            $data = array_map(function (EntityReportDTO $entity) {
33
+                $row = [];
34
+
35
+                foreach ($this->getColumns() as $column) {
36
+                    $columnName = $column->getName();
37
+
38
+                    $row[$columnName] = match ($columnName) {
39
+                        'entity_name' => [
40
+                            'name' => $entity->name,
41
+                            'id' => $entity->id,
42
+                        ],
43
+                        'current' => $entity->aging->current,
44
+                        'over_periods' => $entity->aging->overPeriods,
45
+                        'total' => $entity->aging->total,
46
+                        default => str_starts_with($columnName, 'period_')
47
+                            ? $entity->aging->periods[$columnName] ?? null
48
+                            : '',
49
+                    };
50
+                }
51
+
52
+                return $row;
53
+            }, $category);
54
+
55
+            $categories[] = new ReportCategoryDTO(
56
+                header: null,
57
+                data: $data,
58
+                summary: null,
59
+            );
60
+        }
61
+
62
+        return $categories;
63
+    }
64
+
65
+    public function getOverallTotals(): array
66
+    {
67
+        $totals = [];
68
+
69
+        foreach ($this->getColumns() as $column) {
70
+            $columnName = $column->getName();
71
+
72
+            $totals[$columnName] = match ($columnName) {
73
+                'entity_name' => 'Total',
74
+                'current' => $this->report->agingSummary->current,
75
+                'over_periods' => $this->report->agingSummary->overPeriods,
76
+                'total' => $this->report->agingSummary->total,
77
+                default => str_starts_with($columnName, 'period_')
78
+                    ? $this->report->agingSummary->periods[$columnName] ?? null
79
+                    : '',
80
+            };
81
+        }
82
+
83
+        return $totals;
84
+    }
85
+}

+ 1
- 1
resources/views/components/company/reports/layout.blade.php Bestand weergeven

@@ -50,7 +50,7 @@
50 50
 
51 51
         .table-class {
52 52
             border-collapse: collapse;
53
-            table-layout: fixed;
53
+            table-layout: auto;
54 54
             width: 100%;
55 55
         }
56 56
 

+ 18
- 14
resources/views/components/company/reports/report-pdf.blade.php Bestand weergeven

@@ -22,13 +22,15 @@
22 22
         </thead>
23 23
         @foreach($report->getCategories() as $category)
24 24
             <tbody>
25
-            <tr class="category-header-row">
26
-                @foreach($category->header as $index => $header)
27
-                    <td class="{{ $report->getAlignmentClass($index) }}">
28
-                        {{ $header }}
29
-                    </td>
30
-                @endforeach
31
-            </tr>
25
+            @if(! empty($category->header))
26
+                <tr class="category-header-row">
27
+                    @foreach($category->header as $index => $header)
28
+                        <td class="{{ $report->getAlignmentClass($index) }}">
29
+                            {{ $header }}
30
+                        </td>
31
+                    @endforeach
32
+                </tr>
33
+            @endif
32 34
             @foreach($category->data as $account)
33 35
                 <tr>
34 36
                     @foreach($account as $index => $cell)
@@ -97,13 +99,15 @@
97 99
                 </tr>
98 100
             @endforeach
99 101
 
100
-            <tr class="category-summary-row">
101
-                @foreach($category->summary as $index => $cell)
102
-                    <td class="{{ $report->getAlignmentClass($index) }}">
103
-                        {{ $cell }}
104
-                    </td>
105
-                @endforeach
106
-            </tr>
102
+            @if(! empty($category->summary))
103
+                <tr class="category-summary-row">
104
+                    @foreach($category->summary as $index => $cell)
105
+                        <td class="{{ $report->getAlignmentClass($index) }}">
106
+                            {{ $cell }}
107
+                        </td>
108
+                    @endforeach
109
+                </tr>
110
+            @endif
107 111
 
108 112
             @unless($loop->last && empty($report->getOverallTotals()))
109 113
                 <tr class="spacer-row">

+ 1
- 1
resources/views/components/company/tables/header.blade.php Bestand weergeven

@@ -3,7 +3,7 @@
3 3
     'alignmentClass',
4 4
 ])
5 5
 
6
-<thead class="divide-y divide-gray-200 dark:divide-white/5">
6
+<thead class="divide-y divide-gray-200 dark:divide-white/5 whitespace-nowrap">
7 7
 <tr class="bg-gray-50 dark:bg-white/5">
8 8
     @foreach($headers as $headerIndex => $headerCell)
9 9
         <th class="px-3 py-3.5 sm:first-of-type:ps-6 sm:last-of-type:pe-6 {{ $alignmentClass($headerIndex) }}">

+ 19
- 15
resources/views/components/company/tables/reports/detailed-report.blade.php Bestand weergeven

@@ -2,8 +2,10 @@
2 2
     <x-company.tables.header :headers="$report->getHeaders()" :alignment-class="[$report, 'getAlignmentClass']"/>
3 3
     @foreach($report->getCategories() as $accountCategory)
4 4
         <tbody class="divide-y divide-gray-200 whitespace-nowrap dark:divide-white/5">
5
-        <x-company.tables.category-header :category-headers="$accountCategory->header"
6
-                                          :alignment-class="[$report, 'getAlignmentClass']"/>
5
+        @if(! empty($accountCategory->header))
6
+            <x-company.tables.category-header :category-headers="$accountCategory->header"
7
+                                              :alignment-class="[$report, 'getAlignmentClass']"/>
8
+        @endif
7 9
         @foreach($accountCategory->data as $categoryAccount)
8 10
             <tr>
9 11
                 @foreach($categoryAccount as $accountIndex => $categoryAccountCell)
@@ -48,19 +50,21 @@
48 50
                 @endforeach
49 51
             </tr>
50 52
         @endforeach
51
-        <tr>
52
-            @foreach($accountCategory->summary as $accountCategorySummaryIndex => $accountCategorySummaryCell)
53
-                <x-company.tables.cell :alignment-class="$report->getAlignmentClass($accountCategorySummaryIndex)"
54
-                                       bold="true">
55
-                    {{ $accountCategorySummaryCell }}
56
-                </x-company.tables.cell>
57
-            @endforeach
58
-        </tr>
59
-        <tr>
60
-            <td colspan="{{ count($report->getHeaders()) }}">
61
-                <div class="min-h-12"></div>
62
-            </td>
63
-        </tr>
53
+        @if(! empty($accountCategory->summary))
54
+            <tr>
55
+                @foreach($accountCategory->summary as $accountCategorySummaryIndex => $accountCategorySummaryCell)
56
+                    <x-company.tables.cell :alignment-class="$report->getAlignmentClass($accountCategorySummaryIndex)"
57
+                                           bold="true">
58
+                        {{ $accountCategorySummaryCell }}
59
+                    </x-company.tables.cell>
60
+                @endforeach
61
+            </tr>
62
+            <tr>
63
+                <td colspan="{{ count($report->getHeaders()) }}">
64
+                    <div class="min-h-12"></div>
65
+                </td>
66
+            </tr>
67
+        @endif
64 68
         </tbody>
65 69
     @endforeach
66 70
     <x-company.tables.footer :totals="$report->getOverallTotals()" :alignment-class="[$report, 'getAlignmentClass']"/>

+ 3
- 3
resources/views/components/report-entry.blade.php Bestand weergeven

@@ -10,14 +10,14 @@
10 10
         @class([
11 11
             'inline-flex rounded-lg p-3 ring-4 ring-white dark:ring-gray-900',
12 12
             match ($iconColor) {
13
-                'gray' => 'fi-color-gray bg-gray-50 text-gray-700 dark:bg-gray-900 dark:text-gray-500',
14
-                default => 'fi-color-custom bg-custom-50 text-custom-700 dark:bg-custom-950 dark:text-custom-500',
13
+                'gray' => 'fi-color-gray bg-gray-50 text-gray-700 dark:bg-gray-950 dark:text-gray-300',
14
+                default => 'fi-color-custom bg-custom-50 text-custom-700 dark:bg-custom-950 dark:text-custom-300',
15 15
             },
16 16
         ])
17 17
         @style([
18 18
             \Filament\Support\get_color_css_variables(
19 19
                 $iconColor,
20
-                shades: [50, 500, 700, 950],
20
+                shades: [50, 300, 700, 950],
21 21
             ) => $iconColor !== 'gray',
22 22
         ])
23 23
     >

+ 3
- 0
resources/views/filament/company/pages/reports/accounts-receivable-aging.blade.php Bestand weergeven

@@ -0,0 +1,3 @@
1
+<x-filament-panels::page>
2
+
3
+</x-filament-panels::page>

Laden…
Annuleren
Opslaan