Bläddra i källkod

wip add aging reports

3.x
Andrew Wallo 8 månader sedan
förälder
incheckning
ecc6b6de98

+ 10
- 6
README.md Visa fil

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
 # Getting started
20
 # Getting started
21
 
21
 
203
 
203
 
204
 ## Plaid Integration
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
 ```env
209
 ```env
209
 PLAID_CLIENT_ID=your-client-id
210
 PLAID_CLIENT_ID=your-client-id
212
 PLAID_WEBHOOK_URL=https://my-static-domain.ngrok-free.app/api/plaid/webhook # Must have /api/plaid/webhook appended
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
 ```bash
223
 ```bash
220
 php artisan queue:work --queue=transactions
224
 php artisan queue:work --queue=transactions
243
 migration is required. For more information on how to write and run tests using
247
 migration is required. For more information on how to write and run tests using
244
 Pest, refer to the official documentation: [Pest Documentation](https://pestphp.com/docs).
248
 Pest, refer to the official documentation: [Pest Documentation](https://pestphp.com/docs).
245
 
249
 
246
-
247
 ## Dependencies
250
 ## Dependencies
248
 
251
 
249
 - [filamentphp/filament](https://github.com/filamentphp/filament) - A collection of beautiful full-stack components
252
 - [filamentphp/filament](https://github.com/filamentphp/filament) - A collection of beautiful full-stack components
254
 - [akaunting/laravel-money](https://github.com/akaunting/laravel-money) - Currency formatting and conversion package for
257
 - [akaunting/laravel-money](https://github.com/akaunting/laravel-money) - Currency formatting and conversion package for
255
   Laravel
258
   Laravel
256
 - [squirephp/squire](https://github.com/squirephp/squire) - A library of static Eloquent models for common fixture data
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
 ***Note*** : It is recommended to read the documentation for all dependencies to get yourself familiar with how the
263
 ***Note*** : It is recommended to read the documentation for all dependencies to get yourself familiar with how the
260
 application works.
264
 application works.

+ 8
- 0
app/Contracts/MoneyFormattableDTO.php Visa fil

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 Visa fil

2
 
2
 
3
 namespace App\DTO;
3
 namespace App\DTO;
4
 
4
 
5
-class AccountBalanceDTO
5
+use App\Contracts\MoneyFormattableDTO;
6
+
7
+class AccountBalanceDTO implements MoneyFormattableDTO
6
 {
8
 {
7
     public function __construct(
9
     public function __construct(
8
         public ?string $startingBalance,
10
         public ?string $startingBalance,
11
         public ?string $netMovement,
13
         public ?string $netMovement,
12
         public ?string $endingBalance,
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 Visa fil

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 Visa fil

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 Visa fil

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 Visa fil

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

+ 12
- 0
app/DTO/VendorReportDTO.php Visa fil

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 Visa fil

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 Visa fil

3
 namespace App\Filament\Company\Pages;
3
 namespace App\Filament\Company\Pages;
4
 
4
 
5
 use App\Filament\Company\Pages\Reports\AccountBalances;
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
 use App\Filament\Company\Pages\Reports\AccountTransactions;
8
 use App\Filament\Company\Pages\Reports\AccountTransactions;
7
 use App\Filament\Company\Pages\Reports\BalanceSheet;
9
 use App\Filament\Company\Pages\Reports\BalanceSheet;
8
 use App\Filament\Company\Pages\Reports\CashFlowStatement;
10
 use App\Filament\Company\Pages\Reports\CashFlowStatement;
55
                             ->heading('Income Statement')
57
                             ->heading('Income Statement')
56
                             ->description('Shows revenue, expenses, and net earnings over a period, indicating overall financial performance.')
58
                             ->description('Shows revenue, expenses, and net earnings over a period, indicating overall financial performance.')
57
                             ->icon('heroicon-o-chart-bar')
59
                             ->icon('heroicon-o-chart-bar')
58
-                            ->iconColor(Color::Indigo)
60
+                            ->iconColor(Color::Purple)
59
                             ->url(IncomeStatement::getUrl()),
61
                             ->url(IncomeStatement::getUrl()),
60
                         ReportEntry::make('balance_sheet')
62
                         ReportEntry::make('balance_sheet')
61
                             ->hiddenLabel()
63
                             ->hiddenLabel()
62
                             ->heading('Balance Sheet')
64
                             ->heading('Balance Sheet')
63
                             ->description('Displays your company’s assets, liabilities, and equity at a single point in time, showing overall financial health and stability.')
65
                             ->description('Displays your company’s assets, liabilities, and equity at a single point in time, showing overall financial health and stability.')
64
                             ->icon('heroicon-o-clipboard-document-list')
66
                             ->icon('heroicon-o-clipboard-document-list')
65
-                            ->iconColor(Color::Emerald)
67
+                            ->iconColor(Color::Teal)
66
                             ->url(BalanceSheet::getUrl()),
68
                             ->url(BalanceSheet::getUrl()),
67
                         ReportEntry::make('cash_flow_statement')
69
                         ReportEntry::make('cash_flow_statement')
68
                             ->hiddenLabel()
70
                             ->hiddenLabel()
72
                             ->iconColor(Color::Cyan)
74
                             ->iconColor(Color::Cyan)
73
                             ->url(CashFlowStatement::getUrl()),
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
                 Section::make('Detailed Reports')
117
                 Section::make('Detailed Reports')
76
                     ->aside()
118
                     ->aside()
77
                     ->description('Detailed reports that provide a comprehensive view of your company’s financial transactions and account balances.')
119
                     ->description('Detailed reports that provide a comprehensive view of your company’s financial transactions and account balances.')
81
                             ->hiddenLabel()
123
                             ->hiddenLabel()
82
                             ->heading('Account Balances')
124
                             ->heading('Account Balances')
83
                             ->description('Lists all accounts and their balances, including starting, debit, credit, net movement, and ending balances.')
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
                             ->url(AccountBalances::getUrl()),
128
                             ->url(AccountBalances::getUrl()),
87
                         ReportEntry::make('trial_balance')
129
                         ReportEntry::make('trial_balance')
88
                             ->hiddenLabel()
130
                             ->hiddenLabel()
95
                             ->hiddenLabel()
137
                             ->hiddenLabel()
96
                             ->heading('Account Transactions')
138
                             ->heading('Account Transactions')
97
                             ->description('A record of all transactions, essential for monitoring and reconciling financial activity in the ledger.')
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
                             ->url(AccountTransactions::getUrl()),
142
                             ->url(AccountTransactions::getUrl()),
101
                     ]),
143
                     ]),
102
             ]);
144
             ]);

+ 13
- 0
app/Filament/Company/Pages/Reports/AccountsPayableAging.php Visa fil

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 Visa fil

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 Visa fil

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 Visa fil

370
 
370
 
371
         return $earliestDate ?? today()->toDateTimeString();
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 Visa fil

221
      * @throws CannotInsertRecord
221
      * @throws CannotInsertRecord
222
      * @throws Exception
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
         // Output data rows
236
         // Output data rows

+ 94
- 14
app/Services/ReportService.php Visa fil

2
 
2
 
3
 namespace App\Services;
3
 namespace App\Services;
4
 
4
 
5
+use App\Contracts\MoneyFormattableDTO;
5
 use App\DTO\AccountBalanceDTO;
6
 use App\DTO\AccountBalanceDTO;
6
 use App\DTO\AccountCategoryDTO;
7
 use App\DTO\AccountCategoryDTO;
7
 use App\DTO\AccountDTO;
8
 use App\DTO\AccountDTO;
8
 use App\DTO\AccountTransactionDTO;
9
 use App\DTO\AccountTransactionDTO;
9
 use App\DTO\AccountTypeDTO;
10
 use App\DTO\AccountTypeDTO;
11
+use App\DTO\AgingBucketDTO;
10
 use App\DTO\CashFlowOverviewDTO;
12
 use App\DTO\CashFlowOverviewDTO;
13
+use App\DTO\EntityReportDTO;
11
 use App\DTO\ReportDTO;
14
 use App\DTO\ReportDTO;
12
 use App\Enums\Accounting\AccountCategory;
15
 use App\Enums\Accounting\AccountCategory;
13
 use App\Enums\Accounting\AccountType;
16
 use App\Enums\Accounting\AccountType;
17
+use App\Enums\Accounting\DocumentEntityType;
14
 use App\Enums\Accounting\TransactionType;
18
 use App\Enums\Accounting\TransactionType;
15
 use App\Models\Accounting\Account;
19
 use App\Models\Accounting\Account;
16
 use App\Models\Accounting\Transaction;
20
 use App\Models\Accounting\Transaction;
27
         protected AccountService $accountService,
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
     public function buildAccountBalanceReport(string $startDate, string $endDate, array $columns = []): ReportDTO
56
     public function buildAccountBalanceReport(string $startDate, string $endDate, array $columns = []): ReportDTO
361
 
370
 
362
         $formattedReportTotalBalances = $this->formatBalances($reportTotalBalances);
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
         $retainedEarningsAmount = $this->calculateRetainedEarnings($startDate, $endDate)->getAmount();
378
         $retainedEarningsAmount = $this->calculateRetainedEarnings($startDate, $endDate)->getAmount();
370
 
379
 
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
         $totalInflow = 0;
521
         $totalInflow = 0;
513
         $totalOutflow = 0;
522
         $totalOutflow = 0;
819
             endDate: $asOfDateCarbon,
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 Visa fil

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 Visa fil

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 Visa fil

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 Visa fil

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

+ 18
- 14
resources/views/components/company/reports/report-pdf.blade.php Visa fil

22
         </thead>
22
         </thead>
23
         @foreach($report->getCategories() as $category)
23
         @foreach($report->getCategories() as $category)
24
             <tbody>
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
             @foreach($category->data as $account)
34
             @foreach($category->data as $account)
33
                 <tr>
35
                 <tr>
34
                     @foreach($account as $index => $cell)
36
                     @foreach($account as $index => $cell)
97
                 </tr>
99
                 </tr>
98
             @endforeach
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
             @unless($loop->last && empty($report->getOverallTotals()))
112
             @unless($loop->last && empty($report->getOverallTotals()))
109
                 <tr class="spacer-row">
113
                 <tr class="spacer-row">

+ 1
- 1
resources/views/components/company/tables/header.blade.php Visa fil

3
     'alignmentClass',
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
 <tr class="bg-gray-50 dark:bg-white/5">
7
 <tr class="bg-gray-50 dark:bg-white/5">
8
     @foreach($headers as $headerIndex => $headerCell)
8
     @foreach($headers as $headerIndex => $headerCell)
9
         <th class="px-3 py-3.5 sm:first-of-type:ps-6 sm:last-of-type:pe-6 {{ $alignmentClass($headerIndex) }}">
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 Visa fil

2
     <x-company.tables.header :headers="$report->getHeaders()" :alignment-class="[$report, 'getAlignmentClass']"/>
2
     <x-company.tables.header :headers="$report->getHeaders()" :alignment-class="[$report, 'getAlignmentClass']"/>
3
     @foreach($report->getCategories() as $accountCategory)
3
     @foreach($report->getCategories() as $accountCategory)
4
         <tbody class="divide-y divide-gray-200 whitespace-nowrap dark:divide-white/5">
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
         @foreach($accountCategory->data as $categoryAccount)
9
         @foreach($accountCategory->data as $categoryAccount)
8
             <tr>
10
             <tr>
9
                 @foreach($categoryAccount as $accountIndex => $categoryAccountCell)
11
                 @foreach($categoryAccount as $accountIndex => $categoryAccountCell)
48
                 @endforeach
50
                 @endforeach
49
             </tr>
51
             </tr>
50
         @endforeach
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
         </tbody>
68
         </tbody>
65
     @endforeach
69
     @endforeach
66
     <x-company.tables.footer :totals="$report->getOverallTotals()" :alignment-class="[$report, 'getAlignmentClass']"/>
70
     <x-company.tables.footer :totals="$report->getOverallTotals()" :alignment-class="[$report, 'getAlignmentClass']"/>

+ 3
- 3
resources/views/components/report-entry.blade.php Visa fil

10
         @class([
10
         @class([
11
             'inline-flex rounded-lg p-3 ring-4 ring-white dark:ring-gray-900',
11
             'inline-flex rounded-lg p-3 ring-4 ring-white dark:ring-gray-900',
12
             match ($iconColor) {
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
         @style([
17
         @style([
18
             \Filament\Support\get_color_css_variables(
18
             \Filament\Support\get_color_css_variables(
19
                 $iconColor,
19
                 $iconColor,
20
-                shades: [50, 500, 700, 950],
20
+                shades: [50, 300, 700, 950],
21
             ) => $iconColor !== 'gray',
21
             ) => $iconColor !== 'gray',
22
         ])
22
         ])
23
     >
23
     >

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

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

Laddar…
Avbryt
Spara