Browse Source

wip RecordPayments.php

3.x
Andrew Wallo 4 months ago
parent
commit
42b19c9cad

+ 18
- 0
app/Enums/Accounting/InvoiceStatus.php View File

@@ -46,4 +46,22 @@ enum InvoiceStatus: string implements HasColor, HasLabel
46 46
             self::Unsent,
47 47
         ];
48 48
     }
49
+
50
+    public static function unpaidStatuses(): array
51
+    {
52
+        return [
53
+            self::Unsent,
54
+            self::Sent,
55
+            self::Viewed,
56
+            self::Partial,
57
+            self::Overdue,
58
+        ];
59
+    }
60
+
61
+    public static function getUnpaidOptions(): array
62
+    {
63
+        return collect(self::unpaidStatuses())
64
+            ->mapWithKeys(fn (self $case) => [$case->value => $case->getLabel()])
65
+            ->toArray();
66
+    }
49 67
 }

+ 1
- 0
app/Filament/Company/Resources/Sales/InvoiceResource.php View File

@@ -743,6 +743,7 @@ class InvoiceResource extends Resource
743 743
     {
744 744
         return [
745 745
             'index' => Pages\ListInvoices::route('/'),
746
+            'record-payments' => Pages\RecordPayments::route('/record-payments'),
746 747
             'create' => Pages\CreateInvoice::route('/create'),
747 748
             'view' => Pages\ViewInvoice::route('/{record}'),
748 749
             'edit' => Pages\EditInvoice::route('/{record}/edit'),

+ 361
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/RecordPayments.php View File

@@ -0,0 +1,361 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4
+
5
+use App\Enums\Accounting\InvoiceStatus;
6
+use App\Enums\Accounting\PaymentMethod;
7
+use App\Filament\Company\Resources\Sales\InvoiceResource;
8
+use App\Filament\Tables\Columns\CustomTextInputColumn;
9
+use App\Models\Accounting\Invoice;
10
+use App\Models\Accounting\Transaction;
11
+use App\Models\Banking\BankAccount;
12
+use App\Models\Common\Client;
13
+use App\Models\Setting\Currency;
14
+use App\Utilities\Currency\CurrencyAccessor;
15
+use App\Utilities\Currency\CurrencyConverter;
16
+use Filament\Actions;
17
+use Filament\Forms;
18
+use Filament\Forms\Form;
19
+use Filament\Notifications\Notification;
20
+use Filament\Resources\Pages\ListRecords;
21
+use Filament\Support\RawJs;
22
+use Filament\Tables;
23
+use Filament\Tables\Columns\Summarizers\Summarizer;
24
+use Filament\Tables\Columns\TextColumn;
25
+use Filament\Tables\Table;
26
+use Illuminate\Contracts\Support\Htmlable;
27
+use Illuminate\Database\Eloquent\Collection;
28
+use Illuminate\Database\Query\Builder;
29
+use Illuminate\Support\Str;
30
+use Livewire\Attributes\Computed;
31
+
32
+/**
33
+ * @property Form $form
34
+ */
35
+class RecordPayments extends ListRecords
36
+{
37
+    protected static string $resource = InvoiceResource::class;
38
+
39
+    protected static string $view = 'filament.company.resources.sales.invoice-resource.pages.record-payments';
40
+
41
+    public array $paymentAmounts = [];
42
+
43
+    public ?array $data = [];
44
+
45
+    public function getBreadcrumb(): ?string
46
+    {
47
+        return 'Record Payments';
48
+    }
49
+
50
+    public function getTitle(): string | Htmlable
51
+    {
52
+        return 'Record Payments';
53
+    }
54
+
55
+    public function mount(): void
56
+    {
57
+        parent::mount();
58
+
59
+        $this->form->fill();
60
+
61
+        $this->reset('tableFilters');
62
+    }
63
+
64
+    protected function getHeaderActions(): array
65
+    {
66
+        return [
67
+            Actions\Action::make('processPayments')
68
+                ->color('primary')
69
+                ->action(function () {
70
+                    $data = $this->data;
71
+                    $tableRecords = $this->getTableRecords();
72
+                    $paidCount = 0;
73
+                    $totalPaid = 0;
74
+
75
+                    /** @var Invoice $invoice */
76
+                    foreach ($tableRecords as $invoice) {
77
+                        if (! $invoice->canRecordPayment()) {
78
+                            continue;
79
+                        }
80
+
81
+                        // Get the payment amount from our component state
82
+                        $paymentAmount = $this->getPaymentAmount($invoice);
83
+
84
+                        if ($paymentAmount <= 0) {
85
+                            continue;
86
+                        }
87
+
88
+                        $paymentData = [
89
+                            'posted_at' => $data['posted_at'],
90
+                            'payment_method' => $data['payment_method'],
91
+                            'bank_account_id' => $data['bank_account_id'],
92
+                            'amount' => $paymentAmount,
93
+                        ];
94
+
95
+                        $invoice->recordPayment($paymentData);
96
+                        $paidCount++;
97
+                        $totalPaid += $paymentAmount;
98
+                    }
99
+
100
+                    $currencyCode = $this->getTableFilterState('currency_code')['value'];
101
+                    $totalFormatted = CurrencyConverter::formatCentsToMoney($totalPaid, $currencyCode, true);
102
+
103
+                    Notification::make()
104
+                        ->title('Payments recorded successfully')
105
+                        ->body("Recorded {$paidCount} " . Str::plural('payment', $paidCount) . " for a total of {$totalFormatted}")
106
+                        ->success()
107
+                        ->send();
108
+
109
+                    $this->reset('paymentAmounts');
110
+
111
+                    $this->resetTable();
112
+                }),
113
+        ];
114
+    }
115
+
116
+    /**
117
+     * @return array<int | string, string | Form>
118
+     */
119
+    protected function getForms(): array
120
+    {
121
+        return [
122
+            'form',
123
+        ];
124
+    }
125
+
126
+    public function form(Form $form): Form
127
+    {
128
+        return $form
129
+            ->live()
130
+            ->schema([
131
+                Forms\Components\Grid::make(3)
132
+                    ->schema([
133
+                        Forms\Components\Select::make('bank_account_id')
134
+                            ->label('Account')
135
+                            ->options(static function () {
136
+                                return Transaction::getBankAccountOptionsFlat();
137
+                            })
138
+                            ->default(fn () => BankAccount::where('enabled', true)->first()?->id)
139
+                            ->selectablePlaceholder(false)
140
+                            ->searchable()
141
+                            ->softRequired(),
142
+                        Forms\Components\DatePicker::make('posted_at')
143
+                            ->label('Date')
144
+                            ->default(now())
145
+                            ->softRequired(),
146
+                        Forms\Components\Select::make('payment_method')
147
+                            ->label('Payment method')
148
+                            ->selectablePlaceholder(false)
149
+                            ->options(PaymentMethod::class)
150
+                            ->default(PaymentMethod::BankPayment)
151
+                            ->softRequired(),
152
+                    ]),
153
+            ])->statePath('data');
154
+    }
155
+
156
+    public function table(Table $table): Table
157
+    {
158
+        return $table
159
+            ->query(
160
+                Invoice::query()
161
+                    ->with(['client'])
162
+                    ->unpaid()
163
+            )
164
+            ->recordClasses(['is-spreadsheet'])
165
+            ->defaultSort('due_date')
166
+            ->paginated(false)
167
+            ->columns([
168
+                TextColumn::make('client.name')
169
+                    ->label('Client')
170
+                    ->sortable(),
171
+                TextColumn::make('invoice_number')
172
+                    ->label('Invoice number')
173
+                    ->sortable(),
174
+                TextColumn::make('due_date')
175
+                    ->label('Due date')
176
+                    ->defaultDateFormat()
177
+                    ->sortable(),
178
+                Tables\Columns\TextColumn::make('status')
179
+                    ->badge()
180
+                    ->sortable(),
181
+                TextColumn::make('amount_due')
182
+                    ->label('Amount due')
183
+                    ->currency(static fn (Invoice $record) => $record->currency_code)
184
+                    ->alignEnd()
185
+                    ->sortable()
186
+                    ->summarize([
187
+                        Summarizer::make()
188
+                            ->using(function (Builder $query) {
189
+                                $totalAmountDue = $query->sum('amount_due');
190
+                                $bankAccountCurrency = $this->getSelectedBankAccount()->account->currency_code;
191
+                                $activeCurrency = $this->getTableFilterState('currency_code')['value'] ?? $bankAccountCurrency;
192
+
193
+                                if ($activeCurrency !== $bankAccountCurrency) {
194
+                                    $totalAmountDue = CurrencyConverter::convertBalance($totalAmountDue, $activeCurrency, $bankAccountCurrency);
195
+                                }
196
+
197
+                                return CurrencyConverter::formatCentsToMoney($totalAmountDue, $bankAccountCurrency, true);
198
+                            }),
199
+                        Summarizer::make()
200
+                            ->using(function (Builder $query) {
201
+                                $totalAmountDue = $query->sum('amount_due');
202
+                                $currencyCode = $this->getTableFilterState('currency_code')['value'];
203
+
204
+                                return CurrencyConverter::formatCentsToMoney($totalAmountDue, $currencyCode, true);
205
+                            })
206
+                            ->visible(function () {
207
+                                $activeCurrency = $this->getTableFilterState('currency_code')['value'] ?? null;
208
+                                $bankAccountCurrency = $this->getSelectedBankAccount()->account->currency_code;
209
+
210
+                                return $activeCurrency && $activeCurrency !== $bankAccountCurrency;
211
+                            }),
212
+                    ]),
213
+                CustomTextInputColumn::make('payment_amount')
214
+                    ->label('Payment amount')
215
+                    ->alignEnd()
216
+                    ->navigable()
217
+                    ->mask(RawJs::make('$money($input)'))
218
+                    ->updateStateUsing(function (Invoice $record, $state) {
219
+                        if (! CurrencyConverter::isValidAmount($state, 'USD')) {
220
+                            $this->paymentAmounts[$record->id] = 0;
221
+
222
+                            return '0.00';
223
+                        }
224
+
225
+                        $paymentCents = CurrencyConverter::convertToCents($state, 'USD');
226
+
227
+                        if ($paymentCents > $record->amount_due) {
228
+                            $paymentCents = $record->amount_due;
229
+                        }
230
+
231
+                        $this->paymentAmounts[$record->id] = $paymentCents;
232
+
233
+                        return $state;
234
+                    })
235
+                    ->getStateUsing(function (Invoice $record) {
236
+                        $paymentAmount = $this->paymentAmounts[$record->id] ?? 0;
237
+
238
+                        return CurrencyConverter::convertCentsToFormatSimple($paymentAmount, 'USD');
239
+                    })
240
+                    ->summarize([
241
+                        Summarizer::make()
242
+                            ->using(function () {
243
+                                $total = array_sum($this->paymentAmounts);
244
+                                $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
245
+                                $activeCurrency = $this->getTableFilterState('currency_code')['value'] ?? $defaultCurrency;
246
+
247
+                                if ($activeCurrency !== $defaultCurrency) {
248
+                                    $total = CurrencyConverter::convertBalance($total, $activeCurrency, $defaultCurrency);
249
+                                }
250
+
251
+                                return CurrencyConverter::formatCentsToMoney($total, $defaultCurrency, true);
252
+                            }),
253
+                        Summarizer::make()
254
+                            ->using(fn () => $this->totalPaymentAmount)
255
+                            ->visible(function () {
256
+                                $activeCurrency = $this->getTableFilterState('currency_code')['value'] ?? null;
257
+                                $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
258
+
259
+                                return $activeCurrency && $activeCurrency !== $defaultCurrency;
260
+                            }),
261
+                    ]),
262
+            ])
263
+            ->bulkActions([
264
+                Tables\Actions\BulkAction::make('setFullAmounts')
265
+                    ->label('Set full amounts')
266
+                    ->icon('heroicon-o-banknotes')
267
+                    ->color('primary')
268
+                    ->deselectRecordsAfterCompletion()
269
+                    ->action(function (Collection $records) {
270
+                        $records->each(function (Invoice $invoice) {
271
+                            $this->paymentAmounts[$invoice->id] = $invoice->amount_due;
272
+                        });
273
+                    }),
274
+                Tables\Actions\BulkAction::make('clearAmounts')
275
+                    ->label('Clear amounts')
276
+                    ->icon('heroicon-o-x-mark')
277
+                    ->color('gray')
278
+                    ->deselectRecordsAfterCompletion()
279
+                    ->action(function (Collection $records) {
280
+                        $records->each(function (Invoice $invoice) {
281
+                            $this->paymentAmounts[$invoice->id] = 0;
282
+                        });
283
+                    }),
284
+            ])
285
+            ->filters([
286
+                Tables\Filters\SelectFilter::make('currency_code')
287
+                    ->label('Currency')
288
+                    ->selectablePlaceholder(false)
289
+                    ->default(CurrencyAccessor::getDefaultCurrency())
290
+                    ->options(Currency::query()->pluck('name', 'code')->toArray())
291
+                    ->searchable()
292
+                    ->resetState([
293
+                        'value' => CurrencyAccessor::getDefaultCurrency(),
294
+                    ])
295
+                    ->indicateUsing(function (Tables\Filters\SelectFilter $filter, array $state) {
296
+                        if (blank($state['value'] ?? null)) {
297
+                            return [];
298
+                        }
299
+
300
+                        $label = collect($filter->getOptions())
301
+                            ->mapWithKeys(fn (string | array $label, string $value): array => is_array($label) ? $label : [$value => $label])
302
+                            ->get($state['value']);
303
+
304
+                        if (blank($label)) {
305
+                            return [];
306
+                        }
307
+
308
+                        $indicator = $filter->getLabel();
309
+
310
+                        return Tables\Filters\Indicator::make("{$indicator}: {$label}")->removable(false);
311
+                    }),
312
+                Tables\Filters\SelectFilter::make('client_id')
313
+                    ->label('Client')
314
+                    ->options(fn () => Client::query()->pluck('name', 'id')->toArray())
315
+                    ->searchable()
316
+                    ->default(function () {
317
+                        // TODO: This needs to be applied through the url with a query parameter, it shouldn't be able to be removed though
318
+                        $clientCount = Client::count();
319
+
320
+                        return $clientCount === 1 ? Client::first()?->id : null;
321
+                    }),
322
+                Tables\Filters\SelectFilter::make('status')
323
+                    ->multiple()
324
+                    ->options(InvoiceStatus::getUnpaidOptions()),
325
+            ]);
326
+    }
327
+
328
+    protected function getPaymentAmount(Invoice $record): int
329
+    {
330
+        return $this->paymentAmounts[$record->id] ?? 0;
331
+    }
332
+
333
+    #[Computed]
334
+    public function totalPaymentAmount(): string
335
+    {
336
+        $total = array_sum($this->paymentAmounts);
337
+
338
+        $currencyCode = $this->getTableFilterState('currency_code')['value'];
339
+
340
+        return CurrencyConverter::formatCentsToMoney($total, $currencyCode, true);
341
+    }
342
+
343
+    public function getSelectedBankAccount(): BankAccount
344
+    {
345
+        $bankAccountId = $this->data['bank_account_id'];
346
+
347
+        $bankAccount = BankAccount::find($bankAccountId);
348
+
349
+        return $bankAccount ?: BankAccount::where('enabled', true)->first();
350
+    }
351
+
352
+    protected function handleTableFilterUpdates(): void
353
+    {
354
+        parent::handleTableFilterUpdates();
355
+
356
+        $visibleInvoiceIds = $this->getTableRecords()->pluck('id')->toArray();
357
+        $visibleInvoiceKeys = array_flip($visibleInvoiceIds);
358
+
359
+        $this->paymentAmounts = array_intersect_key($this->paymentAmounts, $visibleInvoiceKeys);
360
+    }
361
+}

+ 1
- 6
app/Models/Accounting/Invoice.php View File

@@ -183,12 +183,7 @@ class Invoice extends Document
183 183
 
184 184
     public function scopeUnpaid(Builder $query): Builder
185 185
     {
186
-        return $query->whereNotIn('status', [
187
-            InvoiceStatus::Paid,
188
-            InvoiceStatus::Void,
189
-            InvoiceStatus::Draft,
190
-            InvoiceStatus::Overpaid,
191
-        ]);
186
+        return $query->whereIn('status', InvoiceStatus::unpaidStatuses());
192 187
     }
193 188
 
194 189
     public function scopeOverdue(Builder $query): Builder

+ 30
- 0
resources/views/filament/company/resources/sales/invoice-resource/pages/record-payments.blade.php View File

@@ -0,0 +1,30 @@
1
+<x-filament-panels::page
2
+    @class([
3
+        'fi-resource-list-records-page',
4
+        'fi-resource-' . str_replace('/', '-', $this->getResource()::getSlug()),
5
+    ])
6
+>
7
+    <div class="flex flex-col gap-y-6">
8
+        <x-filament::section>
9
+            <div class="flex items-center justify-between">
10
+                <div>
11
+                    {{ $this->form }}
12
+                </div>
13
+                <div class="text-right">
14
+                    <div class="text-xs font-medium text-gray-500 dark:text-gray-400">
15
+                        Total Payment Amount
16
+                    </div>
17
+                    <div class="text-3xl font-bold text-gray-900 dark:text-white tabular-nums">{{ $this->totalPaymentAmount }}</div>
18
+                </div>
19
+            </div>
20
+        </x-filament::section>
21
+
22
+        <x-filament-panels::resources.tabs />
23
+
24
+        {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_BEFORE, scopes: $this->getRenderHookScopes()) }}
25
+
26
+        {{ $this->table }}
27
+
28
+        {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_AFTER, scopes: $this->getRenderHookScopes()) }}
29
+    </div>
30
+</x-filament-panels::page>

Loading…
Cancel
Save