Issues (196)

src/Service/Planning/BillSyncService.php (8 issues)

1
<?php
2
3
namespace Siak\Tontine\Service\Planning;
4
5
use Illuminate\Support\Facades\DB;
6
use Illuminate\Support\Collection;
7
use Illuminate\Support\Str;
8
use Siak\Tontine\Model\Charge;
9
use Siak\Tontine\Model\Member;
10
use Siak\Tontine\Model\OnetimeBill;
11
use Siak\Tontine\Model\Round;
12
use Siak\Tontine\Model\RoundBill;
13
use Siak\Tontine\Model\Session;
14
use Symfony\Component\Uid\Ulid;
15
use DateTime;
16
17
use function array_map;
18
use function collect;
19
use function count;
20
use function now;
21
22
class BillSyncService
23
{
24
    /**
25
     * @var DateTime
26
     */
27
    private DateTime $today;
28
29
    /**
30
     * @var Ulid
31
     */
32
    private Ulid $bulkId;
33
34
    /**
35
     * @var int
36
     */
37
    private int $bulkRank;
38
39
    /**
40
     * @var array
41
     */
42
    private array $existingBills;
43
44
    /**
45
     * @var array
46
     */
47
    private array $newBills;
48
49
    /**
50
     * @param Collection $charges
51
     * @param Collection $members
52
     * @param Round $round
53
     *
54
     * @return void
55
     */
56
    private function initBills(Collection $charges, Collection $members, Round $round): void
57
    {
58
        $this->today = now();
59
        $this->bulkId = Str::ulid();
60
        $this->bulkRank = 0;
61
62
        $this->newBills = [
63
            'bills' => [],
64
            'onetime_bills' => [],
65
            'round_bills' => [],
66
            'session_bills' => [],
67
        ];
68
        // Avoid duplicates creation.
69
        $this->existingBills = [
70
            // Take the onetime bills paid in other rounds/
71
            'onetime_bills' => DB::table(DB::raw('v_paid_onetime_bills as b'))
72
                ->join(DB::raw('charges as c'), 'c.def_id', '=', 'b.charge_def_id')
73
                ->join(DB::raw('members as m'), 'm.def_id', '=', 'b.member_def_id')
74
                ->select([
75
                    DB::raw('c.id as charge_id'),
76
                    DB::raw('m.id as member_id'),
77
                ])
78
                ->whereIn('c.id', $charges->pluck('id'))
79
                ->whereIn('m.id', $members->pluck('id'))
80
                ->where('b.round_id', '!=', $round->id)
81
                ->get()
82
                // And those in the current round.
83
                ->concat(OnetimeBill::select(['charge_id', 'member_id'])
84
                    ->whereIn('charge_id', $charges->pluck('id'))
85
                    ->whereIn('member_id', $members->pluck('id'))
86
                    ->get()),
87
            'round_bills' => RoundBill::select(['charge_id', 'member_id'])
88
                ->whereIn('charge_id', $charges->pluck('id'))
89
                ->whereIn('member_id', $members->pluck('id'))
90
                ->get(),
91
        ];
92
    }
93
94
    /**
95
     * @return void
96
     */
97
    private function saveBills(): void
98
    {
99
        // Save the bills table entries.
100
        DB::table('bills')->insert($this->newBills['bills']);
101
        // Get the ids fom the bills table.
102
        $billIds = DB::table('bills')
103
            ->where('bulk_id', $this->bulkId)
104
            ->pluck('id', 'bulk_rank');
105
        // Save the related tables entries.
106
        foreach(['onetime_bills', 'round_bills', 'session_bills'] as $table)
107
        {
108
            if(count($this->newBills[$table]) > 0)
109
            {
110
                // Replace the bill ranks with the bill ids.
111
                $newBills = array_map(function(array $bill) use($billIds) {
112
                    $bill['bill_id'] = $billIds[$bill['bill_id']];
113
                    return $bill;
114
                }, $this->newBills[$table]);
115
                DB::table($table)->insert($newBills);
116
            }
117
        }
118
    }
119
120
    /**
121
     * @param Charge $charge
122
     * @param string $table
123
     * @param array $billData
124
     *
125
     * @return void
126
     */
127
    private function createBill(Charge $charge, string $table, array $billData): void
128
    {
129
        $bulkRank = ++$this->bulkRank;
130
        $this->newBills['bills'][] = [
131
            'charge' => $charge->def->name,
132
            'amount' => $charge->def->amount,
133
            'lendable' => $charge->def->lendable,
134
            'issued_at' => $this->today,
135
            'bulk_id' => $this->bulkId,
136
            'bulk_rank' => $bulkRank,
137
        ];
138
        // The bull rank is temporarily saved in the bill_id field
139
        $billData['bill_id'] = $bulkRank;
140
        $this->newBills[$table][] = $billData;
141
    }
142
143
    /**
144
     * @param Charge $charge
145
     * @param Member $member
146
     * @param Round $round
147
     *
148
     * @return void
149
     */
150
    private function createOnetimeBill(Charge $charge, Member $member, Round $round): void
151
    {
152
        if($this->existingBills['onetime_bills']->contains(fn($bill) =>
153
            $bill->charge_id === $charge->id && $bill->member_id === $member->id))
154
        {
155
            return;
156
        }
157
158
        $this->createBill($charge, 'onetime_bills', [
159
            'charge_id' => $charge->id,
160
            'member_id' => $member->id,
161
            'round_id' => $round->id,
162
        ]);
163
    }
164
165
    /**
166
     * @param Charge $charge
167
     * @param Member $member
168
     * @param Round $round
169
     *
170
     * @return void
171
     */
172
    private function createRoundBill(Charge $charge, Member $member, Round $round): void
173
    {
174
        if($this->existingBills['round_bills']->contains(fn($bill) =>
175
            $bill->charge_id === $charge->id && $bill->member_id === $member->id))
176
        {
177
            return;
178
        }
179
180
        $this->createBill($charge, 'round_bills', [
181
            'charge_id' => $charge->id,
182
            'member_id' => $member->id,
183
            'round_id' => $round->id,
184
        ]);
185
    }
186
187
    /**
188
     * @param Charge $charge
189
     * @param Member $member
190
     * @param Session $session
191
     *
192
     * @return void
193
     */
194
    private function createSessionBill(Charge $charge, Member $member, Session $session): void
195
    {
196
        $this->createBill($charge, 'session_bills', [
197
            'charge_id' => $charge->id,
198
            'member_id' => $member->id,
199
            'session_id' => $session->id,
200
        ]);
201
    }
202
203
    /**
204
     * @param Charge $charge
205
     * @param Member $member
206
     * @param Round $round
207
     * @param Collection|array $sessions
208
     *
209
     * @return void
210
     */
211
    private function _createBills(Charge $charge, Member $member,
212
        Round $round, Collection|array $sessions): void
213
    {
214
        if($charge->def->period_once)
0 ignored issues
show
The property period_once does not exist on Siak\Tontine\Model\ChargeDef. Did you mean period?
Loading history...
215
        {
216
            $this->createOnetimeBill($charge, $member, $round);
217
            return;
218
        }
219
        if($charge->def->period_round)
0 ignored issues
show
The property period_round does not exist on Siak\Tontine\Model\ChargeDef. Did you mean period?
Loading history...
220
        {
221
            $this->createRoundBill($charge, $member, $round);
222
            return;
223
        }
224
        foreach($sessions as $session)
225
        {
226
            $this->createSessionBill($charge, $member, $session);
227
        }
228
    }
229
230
    /**
231
     * @param Collection $charges
232
     * @param Collection $members
233
     * @param Round $round
234
     * @param Collection|array $sessions
235
     *
236
     * @return void
237
     */
238
    private function createBills(Collection $charges, Collection $members,
239
        Round $round, Collection|array $sessions): void
240
    {
241
        $this->initBills($charges, $members, $round);
242
        foreach($charges as $charge)
243
        {
244
            foreach($members as $member)
245
            {
246
                $this->_createBills($charge, $member, $round, $sessions);
247
            }
248
        }
249
        $this->saveBills();
250
    }
251
252
    /**
253
     * @param Charge|Member $owner
254
     * @param string $foreignKey
255
     *
256
     * @return void
257
     */
258
    private function deleteBills(Charge|Member $owner, string $foreignKey): void
259
    {
260
        $billIds = DB::table('onetime_bills')
261
            ->where($foreignKey, $owner->id)
262
            ->select('bill_id')
263
            ->union(DB::table('round_bills')
264
                ->where($foreignKey, $owner->id)
265
                ->select('bill_id'))
266
            ->union(DB::table('session_bills')
267
                ->where($foreignKey, $owner->id)
268
                ->select('bill_id'))
269
            ->union(DB::table('libre_bills')
270
                ->where($foreignKey, $owner->id)
271
                ->select('bill_id'))
272
            ->pluck('bill_id');
273
        if($billIds->count() === 0)
274
        {
275
            return;
276
        }
277
278
        // Will fail if a settlement exists for any of those bills.
279
        $owner->onetime_bills()->delete();
280
        $owner->round_bills()->delete();
281
        $owner->session_bills()->delete();
282
        $owner->libre_bills()->delete();
283
        DB::table('bills')->whereIn('id', $billIds)->delete();
284
    }
285
286
    /**
287
     * Charges for which bills must be created here.
288
     *
289
     * @param Charge $charge
290
     *
291
     * @return bool
292
     */
293
    private function chargeIsBillable(Charge $charge): bool
294
    {
295
        return $charge->def->is_fee && $charge->def->is_fixed;
0 ignored issues
show
The property is_fixed does not seem to exist on Siak\Tontine\Model\ChargeDef. 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...
The property is_fee does not seem to exist on Siak\Tontine\Model\ChargeDef. 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...
296
    }
297
298
    /**
299
     * @param Round $round
300
     * @param Collection $charges
301
     *
302
     * @return void
303
     */
304
    public function chargesEnabled(Round $round, Collection $charges): void
305
    {
306
        $members = $round->members;
307
        $charges = $charges->filter(fn($charge) =>
308
            $this->chargeIsBillable($charge));
309
        if($charges->count() === 0 || $members->count() === 0)
310
        {
311
            return;
312
        }
313
314
        $sessions = $round->sessions;
315
        $this->createBills($charges, $members, $round, $sessions);
316
    }
317
318
    /**
319
     * @param Round $round
320
     * @param Charge $charge
321
     *
322
     * @return void
323
     */
324
    public function chargeEnabled(Round $round, Charge $charge): void
325
    {
326
        $members = $round->members;
327
        if(!$this->chargeIsBillable($charge) || $members->count() === 0)
328
        {
329
            return;
330
        }
331
332
        $charges = collect([$charge]);
0 ignored issues
show
array($charge) of type array<integer,Siak\Tontine\Model\Charge> is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $value of collect(). ( Ignorable by Annotation )

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

332
        $charges = collect(/** @scrutinizer ignore-type */ [$charge]);
Loading history...
333
        $sessions = $round->sessions;
334
        $this->createBills($charges, $members, $round, $sessions);
335
    }
336
337
    /**
338
     * @param Round $round
339
     * @param Charge $charge
340
     *
341
     * @return void
342
     */
343
    public function chargeRemoved(Round $round, Charge $charge): void
0 ignored issues
show
The parameter $round is not used and could be removed. ( Ignorable by Annotation )

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

343
    public function chargeRemoved(/** @scrutinizer ignore-unused */ Round $round, Charge $charge): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
344
    {
345
        $this->deleteBills($charge, 'charge_id');
346
    }
347
348
    /**
349
     * @param Round $round
350
     * @param Collection $members
351
     *
352
     * @return void
353
     */
354
    public function membersEnabled(Round $round, Collection $members): void
355
    {
356
        $charges = $round->charges->filter(fn($charge) =>
357
            $this->chargeIsBillable($charge));
358
        if($members->count() === 0 || $charges->count() === 0)
359
        {
360
            return;
361
        }
362
363
        $sessions = $round->sessions;
364
        $this->createBills($charges, $members, $round, $sessions);
365
    }
366
367
    /**
368
     * @param Round $round
369
     * @param Member $member
370
     *
371
     * @return void
372
     */
373
    public function memberEnabled(Round $round, Member $member): void
374
    {
375
        $this->membersEnabled($round, collect([$member]));
0 ignored issues
show
array($member) of type array<integer,Siak\Tontine\Model\Member> is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $value of collect(). ( Ignorable by Annotation )

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

375
        $this->membersEnabled($round, collect(/** @scrutinizer ignore-type */ [$member]));
Loading history...
376
    }
377
378
    /**
379
     * @param Round $round
380
     * @param Member $member
381
     *
382
     * @return void
383
     */
384
    public function memberRemoved(Round $round, Member $member): void
0 ignored issues
show
The parameter $round is not used and could be removed. ( Ignorable by Annotation )

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

384
    public function memberRemoved(/** @scrutinizer ignore-unused */ Round $round, Member $member): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
385
    {
386
        $this->deleteBills($member, 'member_id');
387
    }
388
389
    /**
390
     * @param Round $round
391
     * @param Collection|array $sessions
392
     *
393
     * @return void
394
     */
395
    public function sessionsCreated(Round $round, Collection|array $sessions): void
396
    {
397
        $charges = $round->charges->filter(fn($charge) =>
398
            $this->chargeIsBillable($charge));
399
        if($charges->count() === 0 || $round->members->count() === 0)
400
        {
401
            return;
402
        }
403
404
        $members = $round->members;
405
        $this->createBills($charges, $members, $round, $sessions);
406
    }
407
408
    /**
409
     * @param Session $session
410
     *
411
     * @return void
412
     */
413
    public function sessionDeleted(Session $session)
414
    {
415
        // Closures allow to have different objects for select and delete queries.
416
        $sessionBillsQuery = fn() => DB::table('session_bills')
417
            ->where('session_id', $session->id);
418
        $libreBillsQuery = fn() => DB::table('libre_bills')
419
            ->where('session_id', $session->id);
420
        $billIds = $sessionBillsQuery()->select('bill_id')
421
            ->union($libreBillsQuery()->select('bill_id'))
422
            ->pluck('bill_id');
423
        // Will fail if a settlement exists for any of those bills.
424
        $sessionBillsQuery()->delete();
425
        $libreBillsQuery()->delete();
426
        DB::table('bills')->whereIn('id', $billIds)->delete();
427
    }
428
429
    /**
430
     * @param Round $round
431
     *
432
     * @return void
433
     */
434
    public function roundDeleted(Round $round): void
435
    {
436
        // Closures allow to have different objects for select and delete queries.
437
        $roundBillsQuery = fn() => DB::table('round_bills')
438
            ->where('round_id', $round->id);
439
        $onetimeBillsQuery = fn() => DB::table('onetime_bills')
440
            ->where('round_id', $round->id);
441
        $billIds = $roundBillsQuery()->select('bill_id')
442
            ->union($onetimeBillsQuery()->select('bill_id'))
443
            ->pluck('bill_id');
444
        // Will fail if a settlement exists for any of those bills.
445
        $roundBillsQuery()->delete();
446
        $onetimeBillsQuery()->delete();
447
        DB::table('bills')->whereIn('id', $billIds)->delete();
448
    }
449
}
450