Andrew Wallo hace 4 meses
padre
commit
cecdfd9410

+ 1
- 1
app/Filament/Company/Resources/Sales/InvoiceResource.php Ver fichero

@@ -163,7 +163,7 @@ class InvoiceResource extends Resource
163 163
                                         $invoiceDate = $get('date');
164 164
                                         $paymentTerms = $get('payment_terms');
165 165
 
166
-                                        if (! $invoiceDate || $paymentTerms === 'custom') {
166
+                                        if (! $invoiceDate || $paymentTerms === 'custom' || ! $paymentTerms) {
167 167
                                             return;
168 168
                                         }
169 169
 

+ 80
- 94
database/factories/Accounting/InvoiceFactory.php Ver fichero

@@ -15,6 +15,7 @@ use App\Models\Setting\DocumentDefault;
15 15
 use App\Utilities\RateCalculator;
16 16
 use Illuminate\Database\Eloquent\Factories\Factory;
17 17
 use Illuminate\Support\Carbon;
18
+use Random\RandomException;
18 19
 
19 20
 /**
20 21
  * @extends Factory<Invoice>
@@ -33,17 +34,22 @@ class InvoiceFactory extends Factory
33 34
      */
34 35
     public function definition(): array
35 36
     {
36
-        $invoiceDate = $this->faker->dateTimeBetween('-1 year');
37
+        $invoiceDate = $this->faker->dateTimeBetween('-2 months');
37 38
 
38 39
         return [
39 40
             'company_id' => 1,
40
-            'client_id' => fn (array $attributes) => Client::where('company_id', $attributes['company_id'])->inRandomOrder()->value('id'),
41
+            'client_id' => function (array $attributes) {
42
+                return Client::where('company_id', $attributes['company_id'])->inRandomOrder()->value('id')
43
+                    ?? Client::factory()->state([
44
+                        'company_id' => $attributes['company_id'],
45
+                    ]);
46
+            },
41 47
             'header' => 'Invoice',
42 48
             'subheader' => 'Invoice',
43 49
             'invoice_number' => $this->faker->unique()->numerify('INV-####'),
44 50
             'order_number' => $this->faker->unique()->numerify('ORD-####'),
45 51
             'date' => $invoiceDate,
46
-            'due_date' => Carbon::parse($invoiceDate)->addDays($this->faker->numberBetween(14, 60)),
52
+            'due_date' => $this->faker->dateTimeInInterval($invoiceDate, '+3 months'),
47 53
             'status' => InvoiceStatus::Draft,
48 54
             'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
49 55
             'discount_computation' => AdjustmentComputation::Percentage,
@@ -85,58 +91,38 @@ class InvoiceFactory extends Factory
85 91
     public function approved(): static
86 92
     {
87 93
         return $this->afterCreating(function (Invoice $invoice) {
88
-            $this->ensureLineItems($invoice);
89
-
90
-            if (! $invoice->canBeApproved()) {
91
-                return;
92
-            }
93
-
94
-            $approvedAt = Carbon::parse($invoice->date)
95
-                ->addHours($this->faker->numberBetween(1, 24));
96
-
97
-            $invoice->approveDraft($approvedAt);
94
+            $this->performApproval($invoice);
98 95
         });
99 96
     }
100 97
 
101 98
     public function sent(): static
102 99
     {
103 100
         return $this->afterCreating(function (Invoice $invoice) {
104
-            $this->ensureApproved($invoice);
105
-
106
-            $sentAt = Carbon::parse($invoice->approved_at)
107
-                ->addHours($this->faker->numberBetween(1, 24));
108
-
109
-            $invoice->markAsSent($sentAt);
101
+            $this->performSent($invoice);
110 102
         });
111 103
     }
112 104
 
113 105
     public function partial(int $maxPayments = 4): static
114 106
     {
115 107
         return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
116
-            $this->ensureSent($invoice);
117
-
118
-            $this->withPayments(max: $maxPayments, invoiceStatus: InvoiceStatus::Partial)
119
-                ->callAfterCreating(collect([$invoice]));
108
+            $this->performSent($invoice);
109
+            $this->performPayments($invoice, $maxPayments, InvoiceStatus::Partial);
120 110
         });
121 111
     }
122 112
 
123 113
     public function paid(int $maxPayments = 4): static
124 114
     {
125 115
         return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
126
-            $this->ensureSent($invoice);
127
-
128
-            $this->withPayments(max: $maxPayments)
129
-                ->callAfterCreating(collect([$invoice]));
116
+            $this->performSent($invoice);
117
+            $this->performPayments($invoice, $maxPayments, InvoiceStatus::Paid);
130 118
         });
131 119
     }
132 120
 
133 121
     public function overpaid(int $maxPayments = 4): static
134 122
     {
135 123
         return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
136
-            $this->ensureSent($invoice);
137
-
138
-            $this->withPayments(max: $maxPayments, invoiceStatus: InvoiceStatus::Overpaid)
139
-                ->callAfterCreating(collect([$invoice]));
124
+            $this->performSent($invoice);
125
+            $this->performPayments($invoice, $maxPayments, InvoiceStatus::Overpaid);
140 126
         });
141 127
     }
142 128
 
@@ -147,77 +133,98 @@ class InvoiceFactory extends Factory
147 133
                 'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
148 134
             ])
149 135
             ->afterCreating(function (Invoice $invoice) {
150
-                $this->ensureApproved($invoice);
136
+                $this->performApproval($invoice);
151 137
             });
152 138
     }
153 139
 
154
-    public function withPayments(?int $min = null, ?int $max = null, InvoiceStatus $invoiceStatus = InvoiceStatus::Paid): static
140
+    protected function performApproval(Invoice $invoice): void
155 141
     {
156
-        $min ??= 1;
142
+        if (! $invoice->hasLineItems()) {
143
+            throw new \InvalidArgumentException('Cannot approve invoice without line items. Use withLineItems() first.');
144
+        }
157 145
 
158
-        return $this->afterCreating(function (Invoice $invoice) use ($invoiceStatus, $max, $min) {
159
-            $this->ensureSent($invoice);
146
+        if (! $invoice->canBeApproved()) {
147
+            throw new \InvalidArgumentException('Invoice cannot be approved. Current status: ' . $invoice->status->value);
148
+        }
160 149
 
161
-            $invoice->refresh();
150
+        $approvedAt = Carbon::parse($invoice->date)
151
+            ->addHours($this->faker->numberBetween(1, 24));
162 152
 
163
-            $amountDue = $invoice->getRawOriginal('amount_due');
153
+        $invoice->approveDraft($approvedAt);
154
+    }
164 155
 
165
-            $totalAmountDue = match ($invoiceStatus) {
166
-                InvoiceStatus::Overpaid => $amountDue + random_int(1000, 10000),
167
-                InvoiceStatus::Partial => (int) floor($amountDue * 0.5),
168
-                default => $amountDue,
169
-            };
156
+    protected function performSent(Invoice $invoice): void
157
+    {
158
+        if (! $invoice->wasApproved()) {
159
+            $this->performApproval($invoice);
160
+        }
170 161
 
171
-            if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
172
-                return;
173
-            }
162
+        $sentAt = Carbon::parse($invoice->approved_at)
163
+            ->addHours($this->faker->numberBetween(1, 24));
174 164
 
175
-            $paymentCount = $max && $min ? $this->faker->numberBetween($min, $max) : $min;
176
-            $paymentAmount = (int) floor($totalAmountDue / $paymentCount);
177
-            $remainingAmount = $totalAmountDue;
165
+        $invoice->markAsSent($sentAt);
166
+    }
178 167
 
179
-            $paymentDate = Carbon::parse($invoice->approved_at);
180
-            $paymentDates = [];
168
+    /**
169
+     * @throws RandomException
170
+     */
171
+    protected function performPayments(Invoice $invoice, int $maxPayments, InvoiceStatus $invoiceStatus): void
172
+    {
173
+        $invoice->refresh();
181 174
 
182
-            for ($i = 0; $i < $paymentCount; $i++) {
183
-                $amount = $i === $paymentCount - 1 ? $remainingAmount : $paymentAmount;
175
+        $amountDue = $invoice->getRawOriginal('amount_due');
184 176
 
185
-                if ($amount <= 0) {
186
-                    break;
187
-                }
177
+        $totalAmountDue = match ($invoiceStatus) {
178
+            InvoiceStatus::Overpaid => $amountDue + random_int(1000, 10000),
179
+            InvoiceStatus::Partial => (int) floor($amountDue * 0.5),
180
+            default => $amountDue,
181
+        };
188 182
 
189
-                $postedAt = $paymentDate->copy()->addDays($this->faker->numberBetween(1, 30));
190
-                $paymentDates[] = $postedAt;
183
+        if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
184
+            return;
185
+        }
191 186
 
192
-                $data = [
193
-                    'posted_at' => $postedAt,
194
-                    'amount' => $amount,
195
-                    'payment_method' => $this->faker->randomElement(PaymentMethod::class),
196
-                    'bank_account_id' => BankAccount::where('company_id', $invoice->company_id)->inRandomOrder()->value('id'),
197
-                    'notes' => $this->faker->sentence,
198
-                ];
187
+        $paymentCount = $this->faker->numberBetween(1, $maxPayments);
188
+        $paymentAmount = (int) floor($totalAmountDue / $paymentCount);
189
+        $remainingAmount = $totalAmountDue;
199 190
 
200
-                $invoice->recordPayment($data);
201
-                $remainingAmount -= $amount;
202
-            }
191
+        $paymentDate = Carbon::parse($invoice->approved_at);
192
+        $paymentDates = [];
193
+
194
+        for ($i = 0; $i < $paymentCount; $i++) {
195
+            $amount = $i === $paymentCount - 1 ? $remainingAmount : $paymentAmount;
203 196
 
204
-            if ($invoiceStatus !== InvoiceStatus::Paid) {
205
-                return;
197
+            if ($amount <= 0) {
198
+                break;
206 199
             }
207 200
 
201
+            $postedAt = $paymentDate->copy()->addDays($this->faker->numberBetween(1, 30));
202
+            $paymentDates[] = $postedAt;
203
+
204
+            $data = [
205
+                'posted_at' => $postedAt,
206
+                'amount' => $amount,
207
+                'payment_method' => $this->faker->randomElement(PaymentMethod::class),
208
+                'bank_account_id' => BankAccount::where('company_id', $invoice->company_id)->inRandomOrder()->value('id'),
209
+                'notes' => $this->faker->sentence,
210
+            ];
211
+
212
+            $invoice->recordPayment($data);
213
+            $remainingAmount -= $amount;
214
+        }
215
+
216
+        if ($invoiceStatus === InvoiceStatus::Paid && ! empty($paymentDates)) {
208 217
             $latestPaymentDate = max($paymentDates);
209 218
             $invoice->updateQuietly([
210 219
                 'status' => $invoiceStatus,
211 220
                 'paid_at' => $latestPaymentDate,
212 221
             ]);
213
-        });
222
+        }
214 223
     }
215 224
 
216 225
     public function configure(): static
217 226
     {
218 227
         return $this->afterCreating(function (Invoice $invoice) {
219
-            $this->ensureLineItems($invoice);
220
-
221 228
             $number = DocumentDefault::getBaseNumber() + $invoice->id;
222 229
 
223 230
             $invoice->updateQuietly([
@@ -233,27 +240,6 @@ class InvoiceFactory extends Factory
233 240
         });
234 241
     }
235 242
 
236
-    protected function ensureLineItems(Invoice $invoice): void
237
-    {
238
-        if (! $invoice->hasLineItems()) {
239
-            $this->withLineItems()->callAfterCreating(collect([$invoice]));
240
-        }
241
-    }
242
-
243
-    protected function ensureApproved(Invoice $invoice): void
244
-    {
245
-        if (! $invoice->wasApproved()) {
246
-            $this->approved()->callAfterCreating(collect([$invoice]));
247
-        }
248
-    }
249
-
250
-    protected function ensureSent(Invoice $invoice): void
251
-    {
252
-        if (! $invoice->hasBeenSent()) {
253
-            $this->sent()->callAfterCreating(collect([$invoice]));
254
-        }
255
-    }
256
-
257 243
     protected function recalculateTotals(Invoice $invoice): void
258 244
     {
259 245
         $invoice->refresh();

+ 1
- 0
database/factories/CompanyFactory.php Ver fichero

@@ -133,6 +133,7 @@ class CompanyFactory extends Factory
133 133
 
134 134
             Invoice::factory()
135 135
                 ->count($draftCount)
136
+                ->withLineItems()
136 137
                 ->create([
137 138
                     'company_id' => $company->id,
138 139
                     'created_by' => $company->user_id,

+ 147
- 0
tests/Feature/Accounting/InvoiceTest.php Ver fichero

@@ -0,0 +1,147 @@
1
+<?php
2
+
3
+use App\Enums\Accounting\InvoiceStatus;
4
+use App\Models\Accounting\Invoice;
5
+use App\Utilities\Currency\CurrencyAccessor;
6
+
7
+beforeEach(function () {
8
+    $this->defaultCurrency = CurrencyAccessor::getDefaultCurrency();
9
+    $this->withOfferings();
10
+});
11
+
12
+it('creates a basic invoice with line items and calculates totals correctly', function () {
13
+    $invoice = Invoice::factory()
14
+        ->withLineItems(2)
15
+        ->create();
16
+
17
+    $invoice->refresh();
18
+
19
+    expect($invoice)
20
+        ->hasLineItems()->toBeTrue()
21
+        ->lineItems->count()->toBe(2)
22
+        ->subtotal->toBeGreaterThan(0)
23
+        ->total->toBeGreaterThan(0)
24
+        ->amount_due->toBe($invoice->total);
25
+});
26
+
27
+describe('invoice approval', function () {
28
+    beforeEach(function () {
29
+        $this->invoice = Invoice::factory()
30
+            ->withLineItems()
31
+            ->approved()
32
+            ->create();
33
+    });
34
+
35
+    test('approved invoices are marked as Unsent when not Overdue', function () {
36
+        $this->invoice->update(['due_date' => now()->addDays(30)]);
37
+
38
+        $this->invoice->refresh();
39
+
40
+        expect($this->invoice)
41
+            ->hasLineItems()->toBeTrue()
42
+            ->status->toBe(InvoiceStatus::Unsent)
43
+            ->wasApproved()->toBeTrue()
44
+            ->approvalTransaction->not->toBeNull();
45
+    });
46
+});
47
+
48
+it('creates sent invoices with line items and approval automatically', function () {
49
+    $invoice = Invoice::factory()
50
+        ->withLineItems()
51
+        ->sent()
52
+        ->create();
53
+
54
+    $invoice->refresh();
55
+
56
+    expect($invoice)
57
+        ->hasLineItems()->toBeTrue()
58
+        ->lineItems->count()->toBeGreaterThan(0)
59
+        ->wasApproved()->toBeTrue()
60
+        ->hasBeenSent()->toBeTrue()
61
+        ->status->toBe(InvoiceStatus::Sent);
62
+});
63
+
64
+it('creates paid invoices with line items, approval, and payments automatically', function () {
65
+    $invoice = Invoice::factory()
66
+        ->withLineItems()
67
+        ->paid()
68
+        ->create();
69
+
70
+    $invoice->refresh();
71
+
72
+    expect($invoice)
73
+        ->hasLineItems()->toBeTrue()
74
+        ->lineItems->count()->toBeGreaterThan(0)
75
+        ->wasApproved()->toBeTrue()
76
+        ->hasBeenSent()->toBeTrue()
77
+        ->hasPayments()->toBeTrue()
78
+        ->isPaid()->toBeTrue()
79
+        ->status->toBe(InvoiceStatus::Paid);
80
+});
81
+
82
+it('creates partial invoices with line items and partial payments automatically', function () {
83
+    $invoice = Invoice::factory()
84
+        ->withLineItems()
85
+        ->partial()
86
+        ->create();
87
+
88
+    $invoice->refresh();
89
+
90
+    expect($invoice)
91
+        ->hasLineItems()->toBeTrue()
92
+        ->lineItems->count()->toBeGreaterThan(0)
93
+        ->wasApproved()->toBeTrue()
94
+        ->hasBeenSent()->toBeTrue()
95
+        ->hasPayments()->toBeTrue()
96
+        ->status->toBeIn([InvoiceStatus::Partial, InvoiceStatus::Overdue])
97
+        ->amount_paid->toBeGreaterThan(0)
98
+        ->amount_paid->toBeLessThan($invoice->total);
99
+});
100
+
101
+it('creates overpaid invoices with line items and overpayments automatically', function () {
102
+    $invoice = Invoice::factory()
103
+        ->withLineItems()
104
+        ->overpaid()
105
+        ->create();
106
+
107
+    $invoice->refresh();
108
+
109
+    expect($invoice)
110
+        ->hasLineItems()->toBeTrue()
111
+        ->lineItems->count()->toBeGreaterThan(0)
112
+        ->wasApproved()->toBeTrue()
113
+        ->hasBeenSent()->toBeTrue()
114
+        ->hasPayments()->toBeTrue()
115
+        ->status->toBe(InvoiceStatus::Overpaid)
116
+        ->amount_paid->toBeGreaterThan($invoice->total);
117
+});
118
+
119
+it('creates overdue invoices with line items and approval automatically', function () {
120
+    $invoice = Invoice::factory()
121
+        ->withLineItems()
122
+        ->overdue()
123
+        ->create();
124
+
125
+    $invoice->refresh();
126
+
127
+    expect($invoice)
128
+        ->hasLineItems()->toBeTrue()
129
+        ->lineItems->count()->toBeGreaterThan(0)
130
+        ->wasApproved()->toBeTrue()
131
+        ->status->toBe(InvoiceStatus::Overdue)
132
+        ->due_date->toBeLessThan(now());
133
+});
134
+
135
+it('handles factory configure method without duplicate line items', function () {
136
+    $invoice = Invoice::factory()
137
+        ->withLineItems(2)
138
+        ->create();
139
+
140
+    $invoice->refresh();
141
+
142
+    expect($invoice)
143
+        ->hasLineItems()->toBeTrue()
144
+        ->lineItems->count()->toBe(2)
145
+        ->invoice_number->toStartWith('INV-')
146
+        ->order_number->toStartWith('ORD-');
147
+});

Loading…
Cancelar
Guardar