Passed
Push — main ( 656b91...db5ad9 )
by Thierry
15:24
created

RefundService::canCreateRefund()   B

Complexity

Conditions 8
Paths 4

Size

Total Lines 18
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 8
eloc 7
nc 4
nop 2
dl 0
loc 18
rs 8.4444
c 1
b 0
f 0
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::whereDoesntHave('refund')
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
            ->whereHas('loan', function(Builder $query) {
154
                $query->whereHas('member', function(Builder $query) {
155
                    $query->where('tontine_id', $this->tenantService->tontine()->id);
156
                });
157
            })
158
            ->find($debtId);
159
    }
160
161
    /**
162
     * @param Debt $debt
163
     * @param Session $session
164
     *
165
     * @return bool
166
     */
167
    private function canCreateRefund(Debt $debt, Session $session): bool
168
    {
169
        // Already refunded
170
        // Cannot refund the principal debt in the same session.
171
        if(!$session->opened || $debt->refund !== null ||
172
            $debt->is_principal && $debt->loan->session->id === $session->id)
173
        {
174
            return false;
175
        }
176
        // Cannot refund the interest debt before the principal.
177
        if($debt->is_interest && !$debt->loan->fixed_interest)
178
        {
179
            return $debt->loan->principal_debt->refund !== null;
180
        }
181
182
        // Cannot be refunded before the last partial refund.
183
        $lastRefund = $debt->partial_refunds->sortByDesc('session.start_at')->first();
184
        return !$lastRefund || $lastRefund->session->start_at < $session->start_at;
185
    }
186
187
    /**
188
     * Create a refund.
189
     *
190
     * @param Debt $debt
191
     * @param Session $session The session
192
     *
193
     * @return void
194
     */
195
    public function createRefund(Debt $debt, Session $session): void
196
    {
197
        if(!$this->canCreateRefund($debt, $session))
198
        {
199
            throw new MessageException(trans('meeting.refund.errors.cannot_refund'));
200
        }
201
202
        $refund = new Refund();
203
        $refund->debt()->associate($debt);
204
        $refund->session()->associate($session);
205
        DB::transaction(function() use($debt, $session, $refund) {
206
            $refund->save();
207
            // For simple or compound interest, also save the final amount.
208
            if($debt->is_interest && !$debt->loan->fixed_interest)
209
            {
210
                $debt->amount = $this->debtCalculator->getDebtDueAmount($debt, $session, true);
211
                $debt->save();
212
            }
213
        });
214
    }
215
216
    /**
217
     * @param Debt $debt
218
     * @param Session $session
219
     *
220
     * @return bool
221
     */
222
    private function canDeleteRefund(Debt $debt, Session $session): bool
223
    {
224
        // A refund can only be deleted in the same session it was created.
225
        if(!$session->opened || !$debt->refund || $debt->refund->session_id !== $session->id)
226
        {
227
            return false;
228
        }
229
230
        return true;
231
    }
232
233
    /**
234
     * Delete a refund.
235
     *
236
     * @param Debt $debt
237
     * @param Session $session The session
238
     *
239
     * @return void
240
     */
241
    public function deleteRefund(Debt $debt, Session $session): void
242
    {
243
        if(!$this->canDeleteRefund($debt, $session))
244
        {
245
            throw new MessageException(trans('meeting.refund.errors.cannot_refund'));
246
        }
247
        if(!$this->paymentService->isEditable($debt->refund))
248
        {
249
            throw new MessageException(trans('meeting.refund.errors.cannot_delete'));
250
        }
251
        $debt->refund->delete();
252
    }
253
254
    /**
255
     * Get the number of partial refunds.
256
     *
257
     * @param Session $session The session
258
     *
259
     * @return int
260
     */
261
    public function getPartialRefundCount(Session $session): int
262
    {
263
        return $session->partial_refunds()->count();
264
    }
265
266
    /**
267
     * Get the partial refunds.
268
     *
269
     * @param Session $session The session
270
     * @param int $page
271
     *
272
     * @return Collection
273
     */
274
    public function getPartialRefunds(Session $session, int $page = 0): Collection
275
    {
276
        return $session->partial_refunds()
277
            ->page($page, $this->tenantService->getLimit())
278
            ->with(['debt.refund', 'debt.loan.member', 'debt.loan.session'])
279
            ->orderBy('id')
280
            ->get()
281
            ->each(function(PartialRefund $refund) use($session) {
282
                $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...
283
            })
284
            ->sortBy('debt.loan.member.name', SORT_LOCALE_STRING)
285
            ->values();
286
    }
287
288
    /**
289
     * Get the ids of all active funds.
290
     *
291
     * @return Collection
292
     */
293
    public function getActiveFundIds(): Collection
294
    {
295
        $tontine = $this->tenantService->tontine();
296
        return $tontine->funds()->active()
297
            ->pluck('id')
298
            ->prepend($tontine->default_fund->id);
299
    }
300
301
    /**
302
     * @param Debt $debt
303
     * @param Session $session
304
     *
305
     * @return bool
306
     */
307
    private function canCreatePartialRefund(Debt $debt, Session $session): bool
308
    {
309
        // Cannot refund the principal debt in the same session.
310
        if(!$session->opened || $debt->refund !== null ||
311
            ($debt->is_principal && $debt->loan->session->id === $session->id))
312
        {
313
            return false;
314
        }
315
316
        return true;
317
    }
318
319
    /**
320
     * Get debt list for dropdown.
321
     *
322
     * @param Session $session The session
323
     *
324
     * @return Collection
325
     */
326
    public function getUnpaidDebtList(Session $session): Collection
327
    {
328
        return $this->fundService->getActiveFunds()
329
            ->reduce(function(Collection $debts, Fund $fund) use($session) {
330
                $fundDebts = $this->getDebtsQuery($session, $fund, false)
331
                    ->with(['loan', 'loan.member', 'loan.session', 'refund', 'refund.session'])
332
                    ->get()
333
                    ->sortBy('loan.member.name', SORT_LOCALE_STRING)
334
                    ->values();
335
                return $debts->concat($fundDebts);
336
            }, collect())
337
            ->filter(function($debt) use($session) {
338
                return $this->canCreatePartialRefund($debt, $session);
339
            })
340
            ->keyBy('id')
341
            ->map(function($debt) use($session) {
342
                $loan = $debt->loan;
343
                $fundTitle = $this->fundService->getFundTitle($loan->fund);
344
                $unpaidAmount = $this->debtCalculator->getDebtUnpaidAmount($debt, $session);
345
346
                return $loan->member->name . " - $fundTitle - " . $loan->session->title .
347
                    ' - ' . trans('meeting.loan.labels.' . $debt->type) .
348
                    ' - ' . $this->localeService->formatMoney($unpaidAmount, true);
349
            });
350
    }
351
352
    /**
353
     * Create a refund.
354
     *
355
     * @param Debt $debt
356
     * @param Session $session The session
357
     * @param int $amount
358
     *
359
     * @return void
360
     */
361
    public function createPartialRefund(Debt $debt, Session $session, int $amount): void
362
    {
363
        if(!$this->canCreatePartialRefund($debt, $session))
364
        {
365
            throw new MessageException(trans('meeting.refund.errors.cannot_delete'));
366
        }
367
        // A partial refund must not totally refund a debt
368
        if($amount >= $this->debtCalculator->getDebtDueAmount($debt, $session, true))
369
        {
370
            throw new MessageException(trans('meeting.refund.errors.pr_amount'));
371
        }
372
373
        $refund = new PartialRefund();
374
        $refund->amount = $amount;
375
        $refund->debt()->associate($debt);
376
        $refund->session()->associate($session);
377
        $refund->save();
378
    }
379
380
    /**
381
     * @param PartialRefund $refund
382
     * @param Session $session
383
     *
384
     * @return bool
385
     */
386
    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...
387
    {
388
        // A partial refund cannot be deleted if the debt is already refunded.
389
        if(!$session->opened || $refund->debt->refund !== null)
390
        {
391
            return false;
392
        }
393
394
        return true;
395
    }
396
397
    /**
398
     * Delete a refund.
399
     *
400
     * @param Session $session The session
401
     * @param int $refundId
402
     *
403
     * @return void
404
     */
405
    public function deletePartialRefund(Session $session, int $refundId): void
406
    {
407
        $refund = PartialRefund::where('session_id', $session->id)
408
            ->with(['debt.refund'])->find($refundId);
409
        if(!$refund)
410
        {
411
            throw new MessageException(trans('meeting.refund.errors.not_found'));
412
        }
413
        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...Relations\HasOneThrough.
Loading history...
Bug introduced by
The property debt does not seem to exist on Illuminate\Database\Eloq...elations\HasManyThrough.
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

413
        if($refund->debt->refund !== null || !$this->paymentService->isEditable(/** @scrutinizer ignore-type */ $refund))
Loading history...
414
        {
415
            throw new MessageException(trans('meeting.refund.errors.cannot_delete'));
416
        }
417
        $refund->delete();
418
    }
419
}
420