Passed
Push — main ( 1074e2...00bd25 )
by Thierry
05:52
created

RefundService::getDebtLabel()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 6
c 1
b 0
f 0
nc 1
nop 2
dl 0
loc 9
rs 10
1
<?php
2
3
namespace Siak\Tontine\Service\Meeting\Credit;
4
5
use Illuminate\Database\Eloquent\Builder;
6
use Illuminate\Database\Eloquent\Relations\Relation;
7
use Illuminate\Support\Collection;
8
use Illuminate\Support\Facades\DB;
9
use Illuminate\Support\Facades\Log;
10
use Siak\Tontine\Exception\MessageException;
11
use Siak\Tontine\Model\Debt;
12
use Siak\Tontine\Model\Fund;
13
use Siak\Tontine\Model\PartialRefund;
14
use Siak\Tontine\Model\Refund;
15
use Siak\Tontine\Model\Session;
16
use Siak\Tontine\Service\LocaleService;
17
use Siak\Tontine\Service\Meeting\PaymentServiceInterface;
18
use Siak\Tontine\Service\Meeting\SessionService;
19
use Siak\Tontine\Service\TenantService;
20
use Siak\Tontine\Service\Tontine\FundService;
21
22
use function collect;
23
use function trans;
24
25
class RefundService
26
{
27
    /**
28
     * @param DebtCalculator $debtCalculator
29
     * @param TenantService $tenantService
30
     * @param LocaleService $localeService
31
     * @param SessionService $sessionService
32
     * @param FundService $fundService
33
     * @param PaymentServiceInterface $paymentService;
34
     */
35
    public function __construct(private DebtCalculator $debtCalculator,
36
        private TenantService $tenantService, private LocaleService $localeService,
37
        private SessionService $sessionService, private FundService $fundService,
38
        private PaymentServiceInterface $paymentService)
39
    {}
40
41
    /**
42
     * @param Session $session The session
43
     * @param Fund $fund
44
     * @param bool|null $onlyPaid
45
     *
46
     * @return Builder|Relation
47
     */
48
    private function getDebtsQuery(Session $session, Fund $fund, ?bool $onlyPaid): Builder|Relation
49
    {
50
        $prevSessions = $this->fundService->getFundSessionIds($session, $fund)
51
            ->filter(fn(int $sessionId) => $sessionId !== $session->id);
52
53
        return Debt::whereHas('loan', function(Builder $query) use($fund) {
0 ignored issues
show
Bug Best Practice introduced by
The expression return Siak\Tontine\Mode...ion(...) { /* ... */ }) could return the type Illuminate\Database\Eloq...gHasThroughRelationship which is incompatible with the type-hinted return Illuminate\Database\Eloq...uent\Relations\Relation. Consider adding an additional type-check to rule them out.
Loading history...
54
                $query->where('fund_id', $fund->id);
55
            })
56
            ->when($onlyPaid === false, function(Builder $query) {
57
                return $query->whereDoesntHave('refund');
58
            })
59
            ->when($onlyPaid === true, function(Builder $query) {
60
                return $query->whereHas('refund');
61
            })
62
            ->where(function(Builder $query) use($session, $prevSessions) {
63
                // Take all the debts in the current session
64
                $query->where(function(Builder $query) use($session) {
65
                    $query->whereHas('loan', function(Builder $query) use($session) {
66
                        $query->where('session_id', $session->id);
67
                    });
68
                });
69
                if($prevSessions->count() === 0)
70
                {
71
                    return;
72
                }
73
                // The debts in the previous sessions.
74
                $query->orWhere(function(Builder $query) use($session, $prevSessions) {
75
                    $query->whereHas('loan', function(Builder $query) use($prevSessions) {
76
                        $query->whereIn('session_id', $prevSessions);
77
                    })
78
                    ->where(function(Builder $query) use($session) {
79
                        // The debts that are not yet refunded.
80
                        $query->orWhereDoesntHave('refund');
81
                        // The debts that are refunded in the current session.
82
                        $query->orWhereHas('refund', function(Builder $query) use($session) {
83
                            $query->where('session_id', $session->id);
84
                        });
85
                    });
86
                });
87
            });
88
    }
89
90
    /**
91
     * Get the number of debts.
92
     *
93
     * @param Session $session The session
94
     * @param Fund $fund
95
     * @param bool|null $onlyPaid
96
     *
97
     * @return int
98
     */
99
    public function getDebtCount(Session $session, Fund $fund, ?bool $onlyPaid): int
100
    {
101
        return $this->getDebtsQuery($session, $fund, $onlyPaid)->count();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->getDebtsQu...nd, $onlyPaid)->count() could return the type Illuminate\Database\Eloquent\Builder which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
102
    }
103
104
    /**
105
     * Get the debts.
106
     *
107
     * @param Session $session The session
108
     * @param Fund $fund
109
     * @param bool|null $onlyPaid
110
     * @param int $page
111
     *
112
     * @return Collection
113
     */
114
    public function getDebts(Session $session, Fund $fund, ?bool $onlyPaid, int $page = 0): Collection
115
    {
116
        return $this->getDebtsQuery($session, $fund, $onlyPaid)
117
            ->page($page, $this->tenantService->getLimit())
118
            ->with(['loan', 'loan.member', 'loan.session', 'refund', 'refund.session',
119
                'partial_refunds', 'partial_refunds.session'])
120
            ->get()
121
            ->each(function(Debt $debt) use($session) {
122
                $debt->isEditable = $debt->refund !== null ?
0 ignored issues
show
Bug introduced by
The property isEditable does not seem to exist on Siak\Tontine\Model\Debt. Are you sure there is no database migration missing?

Checks if undeclared accessed properties appear in database migrations and if the creating migration is correct.

Loading history...
123
                    $this->canDeleteRefund($debt, $session) : $this->canCreateRefund($debt, $session);
124
            })
125
            ->sortBy('loan.member.name', SORT_LOCALE_STRING)
126
            ->values();
127
    }
128
129
    /**
130
     * Get the refunds for a given session.
131
     *
132
     * @param Session $session The session
133
     * @param int $page
134
     *
135
     * @return Collection
136
     */
137
    public function getRefunds(Session $session, int $page = 0): Collection
138
    {
139
        return $session->refunds()
140
            ->page($page, $this->tenantService->getLimit())
141
            ->with('debt.loan.member')
142
            ->get();
143
    }
144
145
    /**
146
     * @param int $debtId
147
     *
148
     * @return Debt|null
149
     */
150
    public function getDebt(int $debtId): ?Debt
151
    {
152
        return Debt::whereHas('loan', function(Builder $query) {
0 ignored issues
show
Bug Best Practice introduced by
The expression return Siak\Tontine\Mode..... */ })->find($debtId) could return the type Illuminate\Database\Eloq...Relations\HasOneThrough which is incompatible with the type-hinted return Siak\Tontine\Model\Debt|null. Consider adding an additional type-check to rule them out.
Loading history...
153
                $query->whereHas('member', function(Builder $query) {
154
                    $query->where('tontine_id', $this->tenantService->tontine()->id);
155
                });
156
            })
157
            ->find($debtId);
158
    }
159
160
    /**
161
     * @param Debt $debt
162
     * @param Session $session
163
     *
164
     * @return bool
165
     */
166
    private function canCreateRefund(Debt $debt, Session $session): bool
167
    {
168
        // Already refunded
169
        // Cannot refund the principal debt in the same session.
170
        if(!$session->opened || $debt->refund !== null ||
171
            $debt->is_principal && $debt->loan->session->id === $session->id)
172
        {
173
            return false;
174
        }
175
        // Cannot refund the interest debt before the principal.
176
        if($debt->is_interest && !$debt->loan->fixed_interest)
177
        {
178
            return $debt->loan->principal_debt->refund !== null;
179
        }
180
181
        // Cannot be refunded before the last partial refund.
182
        $lastRefund = $debt->partial_refunds->sortByDesc('session.start_at')->first();
183
        return !$lastRefund || $lastRefund->session->start_at < $session->start_at;
184
    }
185
186
    /**
187
     * Create a refund.
188
     *
189
     * @param Debt $debt
190
     * @param Session $session The session
191
     *
192
     * @return void
193
     */
194
    public function createRefund(Debt $debt, Session $session): void
195
    {
196
        if(!$this->canCreateRefund($debt, $session))
197
        {
198
            throw new MessageException(trans('meeting.refund.errors.cannot_refund'));
199
        }
200
201
        $refund = new Refund();
202
        $refund->debt()->associate($debt);
203
        $refund->session()->associate($session);
204
        DB::transaction(function() use($debt, $session, $refund) {
205
            $refund->save();
206
            // For simple or compound interest, also save the final amount.
207
            if($debt->is_interest && !$debt->loan->fixed_interest)
208
            {
209
                $debt->amount = $this->debtCalculator->getDebtDueAmount($debt, $session, true);
210
                $debt->save();
211
            }
212
        });
213
    }
214
215
    /**
216
     * @param Debt $debt
217
     * @param Session $session
218
     *
219
     * @return bool
220
     */
221
    private function canDeleteRefund(Debt $debt, Session $session): bool
222
    {
223
        // A refund can only be deleted in the same session it was created.
224
        if(!$session->opened || !$debt->refund || $debt->refund->session_id !== $session->id)
225
        {
226
            return false;
227
        }
228
229
        return true;
230
    }
231
232
    /**
233
     * Delete a refund.
234
     *
235
     * @param Debt $debt
236
     * @param Session $session The session
237
     *
238
     * @return void
239
     */
240
    public function deleteRefund(Debt $debt, Session $session): void
241
    {
242
        if(!$this->canDeleteRefund($debt, $session))
243
        {
244
            throw new MessageException(trans('meeting.refund.errors.cannot_refund'));
245
        }
246
        if(!$this->paymentService->isEditable($debt->refund))
247
        {
248
            throw new MessageException(trans('meeting.refund.errors.cannot_delete'));
249
        }
250
        $debt->refund->delete();
251
    }
252
253
    /**
254
     * Get the number of partial refunds.
255
     *
256
     * @param Session $session The session
257
     *
258
     * @return int
259
     */
260
    public function getPartialRefundCount(Session $session): int
261
    {
262
        return $session->partial_refunds()->count();
263
    }
264
265
    /**
266
     * Get the partial refunds.
267
     *
268
     * @param Session $session The session
269
     * @param int $page
270
     *
271
     * @return Collection
272
     */
273
    public function getPartialRefunds(Session $session, int $page = 0): Collection
274
    {
275
        return $session->partial_refunds()
276
            ->page($page, $this->tenantService->getLimit())
277
            ->with(['debt.refund', 'debt.loan.member', 'debt.loan.session'])
278
            ->orderBy('id')
279
            ->get()
280
            ->each(function(PartialRefund $refund) use($session) {
281
                $refund->debtAmount = $this->debtCalculator->getDebtDueAmount($refund->debt, $session, false);
0 ignored issues
show
Bug introduced by
The property debtAmount does not seem to exist on Siak\Tontine\Model\PartialRefund. Are you sure there is no database migration missing?

Checks if undeclared accessed properties appear in database migrations and if the creating migration is correct.

Loading history...
282
            })
283
            ->sortBy('debt.loan.member.name', SORT_LOCALE_STRING)
284
            ->values();
285
    }
286
287
    /**
288
     * Get the ids of all active funds.
289
     *
290
     * @return Collection
291
     */
292
    public function getActiveFundIds(): Collection
293
    {
294
        $tontine = $this->tenantService->tontine();
295
        return $tontine->funds()->active()
296
            ->pluck('id')
297
            ->prepend($tontine->default_fund->id);
298
    }
299
300
    /**
301
     * @param Debt $debt
302
     * @param Session $session
303
     *
304
     * @return bool
305
     */
306
    private function canCreatePartialRefund(Debt $debt, Session $session): bool
307
    {
308
        // Cannot refund the principal debt in the same session.
309
        if(!$session->opened || $debt->refund !== null ||
310
            ($debt->is_principal && $debt->loan->session->id === $session->id))
311
        {
312
            return false;
313
        }
314
315
        return true;
316
    }
317
318
    /**
319
     * @param Debt $debt
320
     * @param Session $session
321
     *
322
     * @return string
323
     */
324
    public function getDebtLabel(Debt $debt, Session $session): string
325
    {
326
        $loan = $debt->loan;
327
        $fundTitle = $this->fundService->getFundTitle($loan->fund);
328
        $unpaidAmount = $this->debtCalculator->getDebtPayableAmount($debt, $session);
329
330
        return $loan->member->name . " - $fundTitle - " . $loan->session->title .
331
            ' - ' . trans('meeting.loan.labels.' . $debt->type) .
332
            ' - ' . $this->localeService->formatMoney($unpaidAmount, true);
333
    }
334
335
    /**
336
     * Get debt list for dropdown.
337
     *
338
     * @param Session $session The session
339
     *
340
     * @return Collection
341
     */
342
    public function getUnpaidDebtList(Session $session): Collection
343
    {
344
        return $this->fundService->getActiveFunds()
345
            ->reduce(function(Collection $debts, Fund $fund) use($session) {
346
                $fundDebts = $this->getDebtsQuery($session, $fund, false)
347
                    ->with(['loan', 'loan.member', 'loan.session', 'refund', 'refund.session'])
348
                    ->get()
349
                    ->sortBy('loan.member.name', SORT_LOCALE_STRING)
350
                    ->values();
351
                return $debts->concat($fundDebts);
352
            }, collect())
353
            ->filter(fn($debt) => $this->canCreatePartialRefund($debt, $session))
354
            ->keyBy('id')
355
            ->map(fn($debt) => $this->getDebtLabel($debt, $session));
356
    }
357
358
    /**
359
     * Create a refund.
360
     *
361
     * @param Debt $debt
362
     * @param Session $session The session
363
     * @param int $amount
364
     *
365
     * @return void
366
     */
367
    public function createPartialRefund(Debt $debt, Session $session, int $amount): void
368
    {
369
        if(!$this->canCreatePartialRefund($debt, $session))
370
        {
371
            throw new MessageException(trans('meeting.refund.errors.cannot_delete'));
372
        }
373
        // A partial refund must not totally refund a debt
374
        if($amount >= $this->debtCalculator->getDebtPayableAmount($debt, $session))
375
        {
376
            throw new MessageException(trans('meeting.refund.errors.pr_amount'));
377
        }
378
379
        $refund = new PartialRefund();
380
        $refund->amount = $amount;
381
        $refund->debt()->associate($debt);
382
        $refund->session()->associate($session);
383
        $refund->save();
384
    }
385
386
    /**
387
     * @param PartialRefund $refund
388
     * @param Session $session
389
     *
390
     * @return bool
391
     */
392
    private function canDeletePartialRefund(PartialRefund $refund, Session $session): bool
0 ignored issues
show
Unused Code introduced by
The method canDeletePartialRefund() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
393
    {
394
        // A partial refund cannot be deleted if the debt is already refunded.
395
        if(!$session->opened || $refund->debt->refund !== null)
396
        {
397
            return false;
398
        }
399
400
        return true;
401
    }
402
403
    /**
404
     * Find a refund.
405
     *
406
     * @param Session $session The session
407
     * @param int $refundId
408
     *
409
     * @return PartialRefund
410
     */
411
    public function getPartialRefund(Session $session, int $refundId): PartialRefund
412
    {
413
        $refund = PartialRefund::where('session_id', $session->id)
414
            ->with(['debt.refund'])->find($refundId);
415
        if(!$refund)
416
        {
417
            throw new MessageException(trans('meeting.refund.errors.not_found'));
418
        }
419
        if($refund->debt->refund !== null || !$this->paymentService->isEditable($refund))
0 ignored issues
show
Bug introduced by
The property debt does not seem to exist on Illuminate\Database\Eloq...elations\HasManyThrough.
Loading history...
Bug introduced by
The property debt does not seem to exist on Illuminate\Database\Eloq...Relations\HasOneThrough.
Loading history...
Bug introduced by
It seems like $refund can also be of type Illuminate\Database\Eloq...elations\HasManyThrough and Illuminate\Database\Eloq...Relations\HasOneThrough; however, parameter $item of Siak\Tontine\Service\Mee...Interface::isEditable() does only seem to accept Illuminate\Database\Eloquent\Model, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

419
        if($refund->debt->refund !== null || !$this->paymentService->isEditable(/** @scrutinizer ignore-type */ $refund))
Loading history...
420
        {
421
            throw new MessageException(trans('meeting.refund.errors.cannot_delete'));
422
        }
423
        return $refund;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $refund could return the type Illuminate\Database\Eloq...Relations\HasOneThrough which is incompatible with the type-hinted return Siak\Tontine\Model\PartialRefund. Consider adding an additional type-check to rule them out.
Loading history...
424
    }
425
426
    /**
427
     * Update a refund.
428
     *
429
     * @param Session $session The session
430
     * @param int $refundId
431
     * @param int $amount
432
     *
433
     * @return void
434
     */
435
    public function updatePartialRefund(Session $session, int $refundId, int $amount): void
436
    {
437
        $refund = $this->getPartialRefund($session, $refundId);
438
        // A partial refund must not totally refund a debt
439
        if($amount >= $this->debtCalculator->getDebtPayableAmount($refund->debt, $session))
440
        {
441
            throw new MessageException(trans('meeting.refund.errors.pr_amount'));
442
        }
443
444
        $refund->update(['amount' => $amount]);
445
    }
446
447
    /**
448
     * Delete a refund.
449
     *
450
     * @param Session $session The session
451
     * @param int $refundId
452
     *
453
     * @return void
454
     */
455
    public function deletePartialRefund(Session $session, int $refundId): void
456
    {
457
        $this->getPartialRefund($session, $refundId)->delete();
458
    }
459
}
460