Andrew Wallo 5 mēnešus atpakaļ
vecāks
revīzija
2b418c3e8b

+ 0
- 12
app/Enums/Accounting/CreditNoteStatus.php Parādīt failu

@@ -1,12 +0,0 @@
1
-<?php
2
-
3
-namespace App\Enums\Accounting;
4
-
5
-enum CreditNoteStatus: string
6
-{
7
-    case Draft = 'draft';
8
-    case Open = 'open';
9
-    case Closed = 'closed';
10
-    case Applied = 'applied';
11
-    case Partial = 'partial';
12
-}

+ 0
- 533
app/Models/Accounting/CreditNote.php Parādīt failu

@@ -1,533 +0,0 @@
1
-<?php
2
-
3
-namespace App\Models\Accounting;
4
-
5
-use App\Casts\MoneyCast;
6
-use App\Casts\RateCast;
7
-use App\Collections\Accounting\DocumentCollection;
8
-use App\Enums\Accounting\AdjustmentComputation;
9
-use App\Enums\Accounting\CreditNoteStatus;
10
-use App\Enums\Accounting\DocumentDiscountMethod;
11
-use App\Enums\Accounting\DocumentType;
12
-use App\Enums\Accounting\JournalEntryType;
13
-use App\Enums\Accounting\TransactionType;
14
-use App\Filament\Company\Resources\Sales\CreditNoteResource;
15
-use App\Models\Common\Client;
16
-use App\Models\Company;
17
-use App\Models\Setting\DocumentDefault;
18
-use App\Utilities\Currency\CurrencyAccessor;
19
-use App\Utilities\Currency\CurrencyConverter;
20
-use Filament\Actions\Action;
21
-use Filament\Actions\MountableAction;
22
-use Filament\Actions\ReplicateAction;
23
-use Illuminate\Database\Eloquent\Attributes\CollectedBy;
24
-use Illuminate\Database\Eloquent\Casts\Attribute;
25
-use Illuminate\Database\Eloquent\Model;
26
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
27
-use Illuminate\Database\Eloquent\Relations\MorphMany;
28
-use Illuminate\Database\Eloquent\Relations\MorphOne;
29
-use Illuminate\Support\Carbon;
30
-use Illuminate\Support\Collection;
31
-
32
-#[CollectedBy(DocumentCollection::class)]
33
-class CreditNote extends Document
34
-{
35
-    protected $table = 'credit_notes';
36
-
37
-    protected $fillable = [
38
-        'company_id',
39
-        'client_id',
40
-        'logo',
41
-        'header',
42
-        'subheader',
43
-        'credit_note_number',
44
-        'reference_number',
45
-        'date',
46
-        'approved_at',
47
-        'last_sent_at',
48
-        'last_viewed_at',
49
-        'status',
50
-        'currency_code',
51
-        'discount_method',
52
-        'discount_computation',
53
-        'discount_rate',
54
-        'subtotal',
55
-        'tax_total',
56
-        'discount_total',
57
-        'total',
58
-        'amount_used',
59
-        'terms',
60
-        'footer',
61
-        'created_by',
62
-        'updated_by',
63
-    ];
64
-
65
-    protected $casts = [
66
-        'date' => 'date',
67
-        'approved_at' => 'datetime',
68
-        'last_sent_at' => 'datetime',
69
-        'last_viewed_at' => 'datetime',
70
-        'status' => CreditNoteStatus::class,
71
-        'discount_method' => DocumentDiscountMethod::class,
72
-        'discount_computation' => AdjustmentComputation::class,
73
-        'discount_rate' => RateCast::class,
74
-        'subtotal' => MoneyCast::class,
75
-        'tax_total' => MoneyCast::class,
76
-        'discount_total' => MoneyCast::class,
77
-        'total' => MoneyCast::class,
78
-        'amount_used' => MoneyCast::class,
79
-    ];
80
-
81
-    // Basic Relationships
82
-
83
-    public function client(): BelongsTo
84
-    {
85
-        return $this->belongsTo(Client::class);
86
-    }
87
-
88
-    public function company(): BelongsTo
89
-    {
90
-        return $this->belongsTo(Company::class);
91
-    }
92
-
93
-    // Transaction Relationships
94
-
95
-    public function transactions(): MorphMany
96
-    {
97
-        return $this->morphMany(Transaction::class, 'transactionable');
98
-    }
99
-
100
-    public function initialTransaction(): MorphOne
101
-    {
102
-        return $this->morphOne(Transaction::class, 'transactionable')
103
-            ->where('type', TransactionType::Journal);
104
-    }
105
-
106
-    // Track where this credit note has been applied
107
-
108
-    public function applications(): Collection
109
-    {
110
-        // Find all invoice transactions that reference this credit note
111
-        return Transaction::where('type', TransactionType::CreditNote)
112
-            ->where('is_payment', true)
113
-            ->whereJsonContains('meta->credit_note_id', $this->id)
114
-            ->get()
115
-            ->map(function ($transaction) {
116
-                return [
117
-                    'invoice' => $transaction->transactionable,
118
-                    'amount' => $transaction->amount,
119
-                    'date' => $transaction->posted_at,
120
-                    'transaction' => $transaction,
121
-                ];
122
-            })
123
-            ->filter(function ($item) {
124
-                return ! is_null($item['invoice']);
125
-            });
126
-    }
127
-
128
-    public function appliedInvoices(): Collection
129
-    {
130
-        return $this->applications()->pluck('invoice');
131
-    }
132
-
133
-    // Document Interface Implementation
134
-
135
-    public static function documentType(): DocumentType
136
-    {
137
-        return DocumentType::CreditNote;
138
-    }
139
-
140
-    public function documentNumber(): ?string
141
-    {
142
-        return $this->credit_note_number;
143
-    }
144
-
145
-    public function documentDate(): ?string
146
-    {
147
-        return $this->date?->toDefaultDateFormat();
148
-    }
149
-
150
-    public function dueDate(): ?string
151
-    {
152
-        return null;
153
-    }
154
-
155
-    public function amountDue(): ?string
156
-    {
157
-        return null;
158
-    }
159
-
160
-    public function referenceNumber(): ?string
161
-    {
162
-        return $this->reference_number;
163
-    }
164
-
165
-    // Computed Properties
166
-
167
-    protected function availableBalance(): Attribute
168
-    {
169
-        return Attribute::get(function () {
170
-            $totalCents = (int) $this->getRawOriginal('total');
171
-            $amountUsedCents = (int) $this->getRawOriginal('amount_used');
172
-
173
-            return CurrencyConverter::convertCentsToFormatSimple($totalCents - $amountUsedCents);
174
-        });
175
-    }
176
-
177
-    protected function availableBalanceCents(): Attribute
178
-    {
179
-        return Attribute::get(function () {
180
-            $totalCents = (int) $this->getRawOriginal('total');
181
-            $amountUsedCents = (int) $this->getRawOriginal('amount_used');
182
-
183
-            return $totalCents - $amountUsedCents;
184
-        });
185
-    }
186
-
187
-    // Status Methods
188
-
189
-    public function isFullyApplied(): bool
190
-    {
191
-        return $this->availableBalanceCents <= 0;
192
-    }
193
-
194
-    public function isPartiallyApplied(): bool
195
-    {
196
-        $amountUsedCents = (int) $this->getRawOriginal('amount_used');
197
-
198
-        return $amountUsedCents > 0 && ! $this->isFullyApplied();
199
-    }
200
-
201
-    public function isDraft(): bool
202
-    {
203
-        return $this->status === CreditNoteStatus::Draft;
204
-    }
205
-
206
-    public function wasApproved(): bool
207
-    {
208
-        return $this->approved_at !== null;
209
-    }
210
-
211
-    public function hasBeenSent(): bool
212
-    {
213
-        return $this->last_sent_at !== null;
214
-    }
215
-
216
-    public function hasBeenViewed(): bool
217
-    {
218
-        return $this->last_viewed_at !== null;
219
-    }
220
-
221
-    public function canBeAppliedToInvoice(): bool
222
-    {
223
-        return in_array($this->status->value, CreditNoteStatus::canBeApplied()) &&
224
-            $this->availableBalanceCents > 0;
225
-    }
226
-
227
-    // Application Methods
228
-
229
-    public function applyToInvoice(Invoice $invoice, string $amount): void
230
-    {
231
-        // Validate currencies match
232
-        if ($this->currency_code !== $invoice->currency_code) {
233
-            throw new \RuntimeException('Cannot apply credit note with different currency to invoice.');
234
-        }
235
-
236
-        // Validate available amount
237
-        $amountCents = CurrencyConverter::convertToCents($amount, $this->currency_code);
238
-
239
-        if ($amountCents > $this->availableBalanceCents) {
240
-            throw new \RuntimeException('Cannot apply more than the available credit note amount.');
241
-        }
242
-
243
-        // Create transaction on the invoice
244
-        $invoice->transactions()->create([
245
-            'company_id' => $this->company_id,
246
-            'type' => TransactionType::CreditNote,
247
-            'is_payment' => true,
248
-            'posted_at' => now(),
249
-            'amount' => $amount,
250
-            'account_id' => Account::getAccountsReceivableAccount()->id,
251
-            'description' => "Credit Note #{$this->credit_note_number} applied to Invoice #{$invoice->invoice_number}",
252
-            'meta' => [
253
-                'credit_note_id' => $this->id,
254
-            ],
255
-        ]);
256
-
257
-        // Update amount used on credit note
258
-        $this->amount_used = CurrencyConverter::convertCentsToFormatSimple(
259
-            (int) $this->getRawOriginal('amount_used') + $amountCents
260
-        );
261
-        $this->save();
262
-
263
-        // Update status if needed
264
-        $this->updateStatusBasedOnUsage();
265
-
266
-        // Update invoice payment status
267
-        $invoice->updatePaymentStatus();
268
-    }
269
-
270
-    protected function updateStatusBasedOnUsage(): void
271
-    {
272
-        if ($this->isFullyApplied()) {
273
-            $this->status = CreditNoteStatus::Applied;
274
-        } elseif ($this->isPartiallyApplied()) {
275
-            $this->status = CreditNoteStatus::Partial;
276
-        }
277
-
278
-        $this->save();
279
-    }
280
-
281
-    public function autoApplyToInvoices(): void
282
-    {
283
-        // Skip if no available amount
284
-        if ($this->availableBalanceCents <= 0) {
285
-            return;
286
-        }
287
-
288
-        // Find unpaid invoices for this client, ordered by due date (oldest first)
289
-        $unpaidInvoices = Invoice::where('client_id', $this->client_id)
290
-            ->where('currency_code', $this->currency_code)
291
-            ->unpaid()
292
-            ->orderBy('due_date')
293
-            ->get();
294
-
295
-        // Apply to invoices until amount is used up
296
-        foreach ($unpaidInvoices as $invoice) {
297
-            $invoiceAmountDueCents = (int) $invoice->getRawOriginal('amount_due');
298
-
299
-            if ($invoiceAmountDueCents <= 0 || $this->availableBalanceCents <= 0) {
300
-                continue;
301
-            }
302
-
303
-            // Calculate amount to apply to this invoice
304
-            $applyAmountCents = min($this->availableBalanceCents, $invoiceAmountDueCents);
305
-            $applyAmount = CurrencyConverter::convertCentsToFormatSimple($applyAmountCents);
306
-
307
-            // Apply to invoice
308
-            $this->applyToInvoice($invoice, $applyAmount);
309
-
310
-            if ($this->availableBalanceCents <= 0) {
311
-                break;
312
-            }
313
-        }
314
-    }
315
-
316
-    // Accounting Methods
317
-
318
-    public function createInitialTransaction(?Carbon $postedAt = null): void
319
-    {
320
-        $postedAt ??= $this->date;
321
-
322
-        $total = $this->formatAmountToDefaultCurrency($this->getRawOriginal('total'));
323
-
324
-        $transaction = $this->transactions()->create([
325
-            'company_id' => $this->company_id,
326
-            'type' => TransactionType::Journal,
327
-            'posted_at' => $postedAt,
328
-            'amount' => $total,
329
-            'description' => 'Credit Note Creation for Credit Note #' . $this->credit_note_number,
330
-        ]);
331
-
332
-        $baseDescription = "{$this->client->name}: Credit Note #{$this->credit_note_number}";
333
-
334
-        // Credit AR (opposite of invoice)
335
-        $transaction->journalEntries()->create([
336
-            'company_id' => $this->company_id,
337
-            'type' => JournalEntryType::Credit,
338
-            'account_id' => Account::getAccountsReceivableAccount()->id,
339
-            'amount' => $total,
340
-            'description' => $baseDescription,
341
-        ]);
342
-
343
-        // Handle line items - debit revenue accounts (reverse of invoice)
344
-        foreach ($this->lineItems as $lineItem) {
345
-            $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
346
-            $lineItemSubtotal = $this->formatAmountToDefaultCurrency($lineItem->getRawOriginal('subtotal'));
347
-
348
-            $transaction->journalEntries()->create([
349
-                'company_id' => $this->company_id,
350
-                'type' => JournalEntryType::Debit,
351
-                'account_id' => $lineItem->offering->income_account_id,
352
-                'amount' => $lineItemSubtotal,
353
-                'description' => $lineItemDescription,
354
-            ]);
355
-
356
-            // Handle adjustments
357
-            foreach ($lineItem->adjustments as $adjustment) {
358
-                $adjustmentAmount = $this->formatAmountToDefaultCurrency($lineItem->calculateAdjustmentTotalAmount($adjustment));
359
-
360
-                $transaction->journalEntries()->create([
361
-                    'company_id' => $this->company_id,
362
-                    'type' => $adjustment->category->isDiscount() ? JournalEntryType::Credit : JournalEntryType::Debit,
363
-                    'account_id' => $adjustment->account_id,
364
-                    'amount' => $adjustmentAmount,
365
-                    'description' => $lineItemDescription,
366
-                ]);
367
-            }
368
-        }
369
-    }
370
-
371
-    public function approveDraft(?Carbon $approvedAt = null): void
372
-    {
373
-        if (! $this->isDraft()) {
374
-            throw new \RuntimeException('Credit note is not in draft status.');
375
-        }
376
-
377
-        $this->createInitialTransaction();
378
-
379
-        $approvedAt ??= now();
380
-
381
-        $this->update([
382
-            'approved_at' => $approvedAt,
383
-            'status' => CreditNoteStatus::Sent,
384
-        ]);
385
-
386
-        // Auto-apply if configured in settings
387
-        if ($this->company->settings->auto_apply_credit_notes ?? true) {
388
-            $this->autoApplyToInvoices();
389
-        }
390
-    }
391
-
392
-    public function convertAmountToDefaultCurrency(int $amountCents): int
393
-    {
394
-        $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
395
-        $needsConversion = $this->currency_code !== $defaultCurrency;
396
-
397
-        if ($needsConversion) {
398
-            return CurrencyConverter::convertBalance($amountCents, $this->currency_code, $defaultCurrency);
399
-        }
400
-
401
-        return $amountCents;
402
-    }
403
-
404
-    public function formatAmountToDefaultCurrency(int $amountCents): string
405
-    {
406
-        $convertedCents = $this->convertAmountToDefaultCurrency($amountCents);
407
-
408
-        return CurrencyConverter::convertCentsToFormatSimple($convertedCents);
409
-    }
410
-
411
-    // Other methods
412
-
413
-    public function markAsSent(?Carbon $sentAt = null): void
414
-    {
415
-        $sentAt ??= now();
416
-
417
-        $this->update([
418
-            'status' => CreditNoteStatus::Sent,
419
-            'last_sent_at' => $sentAt,
420
-        ]);
421
-    }
422
-
423
-    public function markAsViewed(?Carbon $viewedAt = null): void
424
-    {
425
-        $viewedAt ??= now();
426
-
427
-        $this->update([
428
-            'status' => CreditNoteStatus::Viewed,
429
-            'last_viewed_at' => $viewedAt,
430
-        ]);
431
-    }
432
-
433
-    // Utility Methods
434
-
435
-    public static function getNextDocumentNumber(?Company $company = null): string
436
-    {
437
-        $company ??= auth()->user()?->currentCompany;
438
-
439
-        if (! $company) {
440
-            throw new \RuntimeException('No current company is set for the user.');
441
-        }
442
-
443
-        $defaultSettings = $company->defaultCreditNote;
444
-
445
-        $numberPrefix = $defaultSettings->number_prefix ?? 'CN-';
446
-
447
-        $latestDocument = static::query()
448
-            ->whereNotNull('credit_note_number')
449
-            ->latest('credit_note_number')
450
-            ->first();
451
-
452
-        $lastNumberNumericPart = $latestDocument
453
-            ? (int) substr($latestDocument->credit_note_number, strlen($numberPrefix))
454
-            : DocumentDefault::getBaseNumber();
455
-
456
-        $numberNext = $lastNumberNumericPart + 1;
457
-
458
-        return $defaultSettings->getNumberNext(
459
-            prefix: $numberPrefix,
460
-            next: $numberNext
461
-        );
462
-    }
463
-
464
-    // Action Methods
465
-
466
-    public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
467
-    {
468
-        return $action::make()
469
-            ->excludeAttributes([
470
-                'status',
471
-                'amount_used',
472
-                'created_by',
473
-                'updated_by',
474
-                'created_at',
475
-                'updated_at',
476
-                'credit_note_number',
477
-                'date',
478
-                'approved_at',
479
-                'last_sent_at',
480
-                'last_viewed_at',
481
-            ])
482
-            ->modal(false)
483
-            ->beforeReplicaSaved(function (self $original, self $replica) {
484
-                $replica->status = CreditNoteStatus::Draft;
485
-                $replica->credit_note_number = self::getNextDocumentNumber();
486
-                $replica->date = now();
487
-                $replica->amount_used = 0;
488
-            })
489
-            ->databaseTransaction()
490
-            ->after(function (self $original, self $replica) {
491
-                $original->replicateLineItems($replica);
492
-            })
493
-            ->successRedirectUrl(static function (self $replica) {
494
-                return CreditNoteResource::getUrl('edit', ['record' => $replica]);
495
-            });
496
-    }
497
-
498
-    public static function getApproveAction(string $action = Action::class): MountableAction
499
-    {
500
-        return $action::make('approve')
501
-            ->label('Approve')
502
-            ->icon('heroicon-m-check-circle')
503
-            ->visible(fn (self $record) => $record->isDraft())
504
-            ->requiresConfirmation()
505
-            ->databaseTransaction()
506
-            ->successNotificationTitle('Credit note approved')
507
-            ->action(function (self $record) {
508
-                $record->approveDraft();
509
-            });
510
-    }
511
-
512
-    public function replicateLineItems(Model $target): void
513
-    {
514
-        $this->lineItems->each(function (DocumentLineItem $lineItem) use ($target) {
515
-            $replica = $lineItem->replicate([
516
-                'documentable_id',
517
-                'documentable_type',
518
-                'subtotal',
519
-                'total',
520
-                'created_by',
521
-                'updated_by',
522
-                'created_at',
523
-                'updated_at',
524
-            ]);
525
-
526
-            $replica->documentable_id = $target->id;
527
-            $replica->documentable_type = $target->getMorphClass();
528
-            $replica->save();
529
-
530
-            $replica->adjustments()->sync($lineItem->adjustments->pluck('id'));
531
-        });
532
-    }
533
-}

+ 0
- 12
app/Models/Accounting/DebitNote.php Parādīt failu

@@ -1,12 +0,0 @@
1
-<?php
2
-
3
-namespace App\Models\Accounting;
4
-
5
-use Illuminate\Database\Eloquent\Factories\HasFactory;
6
-use Illuminate\Database\Eloquent\Model;
7
-
8
-class DebitNote extends Model
9
-{
10
-    /** @use HasFactory<\Database\Factories\Accounting\DebitNoteFactory> */
11
-    use HasFactory;
12
-}

+ 0
- 23
database/factories/Accounting/CreditNoteFactory.php Parādīt failu

@@ -1,23 +0,0 @@
1
-<?php
2
-
3
-namespace Database\Factories\Accounting;
4
-
5
-use Illuminate\Database\Eloquent\Factories\Factory;
6
-
7
-/**
8
- * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Accounting\CreditNote>
9
- */
10
-class CreditNoteFactory extends Factory
11
-{
12
-    /**
13
-     * Define the model's default state.
14
-     *
15
-     * @return array<string, mixed>
16
-     */
17
-    public function definition(): array
18
-    {
19
-        return [
20
-            //
21
-        ];
22
-    }
23
-}

+ 0
- 23
database/factories/Accounting/DebitNoteFactory.php Parādīt failu

@@ -1,23 +0,0 @@
1
-<?php
2
-
3
-namespace Database\Factories\Accounting;
4
-
5
-use Illuminate\Database\Eloquent\Factories\Factory;
6
-
7
-/**
8
- * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Accounting\DebitNote>
9
- */
10
-class DebitNoteFactory extends Factory
11
-{
12
-    /**
13
-     * Define the model's default state.
14
-     *
15
-     * @return array<string, mixed>
16
-     */
17
-    public function definition(): array
18
-    {
19
-        return [
20
-            //
21
-        ];
22
-    }
23
-}

+ 0
- 27
database/migrations/2025_04_22_014110_create_credit_notes_table.php Parādīt failu

@@ -1,27 +0,0 @@
1
-<?php
2
-
3
-use Illuminate\Database\Migrations\Migration;
4
-use Illuminate\Database\Schema\Blueprint;
5
-use Illuminate\Support\Facades\Schema;
6
-
7
-return new class extends Migration
8
-{
9
-    /**
10
-     * Run the migrations.
11
-     */
12
-    public function up(): void
13
-    {
14
-        Schema::create('credit_notes', function (Blueprint $table) {
15
-            $table->id();
16
-            $table->timestamps();
17
-        });
18
-    }
19
-
20
-    /**
21
-     * Reverse the migrations.
22
-     */
23
-    public function down(): void
24
-    {
25
-        Schema::dropIfExists('credit_notes');
26
-    }
27
-};

+ 0
- 27
database/migrations/2025_04_22_014129_create_debit_notes_table.php Parādīt failu

@@ -1,27 +0,0 @@
1
-<?php
2
-
3
-use Illuminate\Database\Migrations\Migration;
4
-use Illuminate\Database\Schema\Blueprint;
5
-use Illuminate\Support\Facades\Schema;
6
-
7
-return new class extends Migration
8
-{
9
-    /**
10
-     * Run the migrations.
11
-     */
12
-    public function up(): void
13
-    {
14
-        Schema::create('debit_notes', function (Blueprint $table) {
15
-            $table->id();
16
-            $table->timestamps();
17
-        });
18
-    }
19
-
20
-    /**
21
-     * Reverse the migrations.
22
-     */
23
-    public function down(): void
24
-    {
25
-        Schema::dropIfExists('debit_notes');
26
-    }
27
-};

Notiek ielāde…
Atcelt
Saglabāt