Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.07% covered (warning)
62.07%
72 / 116
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
AdminRepository
62.07% covered (warning)
62.07%
72 / 116
14.29% covered (danger)
14.29%
1 / 7
101.85
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStats
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 getInvestorsList
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
10
 getAumHistory
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 getAllTransactions
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
156
 getLendingActivity
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
3.01
 getInvestorDetail
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Admin\Repository;
6
7use App\Domain\Admin\Data\AdminStatsData;
8use App\Support\Row;
9use PDO;
10use RuntimeException;
11
12use function count;
13
14/**
15 * Repository for admin-specific data access operations.
16 */
17final class AdminRepository
18{
19    public function __construct(
20        private readonly PDO $pdo
21    ) {}
22
23    /**
24     * Get platform-wide statistics for admin dashboard.
25     */
26    public function getStats(): AdminStatsData
27    {
28        $sql = "
29            SELECT
30                (SELECT COUNT(*) FROM investors) as \"totalInvestors\",
31                (SELECT COUNT(*) FROM investors WHERE status = 'active') as \"activeInvestors\",
32                (SELECT COUNT(*) FROM investors WHERE kyc_status = 'pending') as \"pendingKyc\",
33                (SELECT COUNT(*) FROM accounts) as \"totalAccounts\",
34                (SELECT COUNT(*) FROM accounts WHERE status = 'active') as \"activeAccounts\",
35                (SELECT COUNT(*) FROM accounts WHERE status = 'pending') as \"pendingAccounts\",
36                (SELECT COALESCE(SUM(balance), 0)::TEXT FROM accounts) as \"totalAum\",
37                (SELECT COALESCE(SUM(available_for_loan), 0)::TEXT FROM accounts) as \"totalAvailableForLoan\",
38                (SELECT COALESCE(SUM(outstanding_balance), 0)::TEXT FROM lending.loans WHERE status IN ('active', 'disbursed')) as \"loansOutstanding\"
39        ";
40
41        $stmt = $this->pdo->query($sql);
42
43        if ($stmt === false) {
44            throw new RuntimeException('Failed to execute stats query');
45        }
46
47        $row = $stmt->fetch(PDO::FETCH_ASSOC);
48
49        if (!is_array($row)) {
50            throw new RuntimeException('Stats query returned no row');
51        }
52
53        return new AdminStatsData($row);
54    }
55
56    /**
57     * Get paginated list of all investors with account info.
58     *
59     * @param int $page
60     * @param int $limit
61     * @param string|null $search Search by name or email
62     * @param string|null $kycStatus Filter by KYC status
63     * @param string|null $status Filter by investor status
64     *
65     * @return array{investors: array<mixed>, total: int}
66     */
67    public function getInvestorsList(
68        int $page = 1,
69        int $limit = 20,
70        ?string $search = null,
71        ?string $kycStatus = null,
72        ?string $status = null,
73    ): array {
74        $offset = ($page - 1) * $limit;
75        $params = [];
76        $whereClauses = [];
77
78        // Build WHERE clauses
79        if ($search !== null && $search !== '') {
80            $whereClauses[] = '(i.first_name ILIKE :search OR i.last_name ILIKE :search OR i.email ILIKE :search OR a.account_number ILIKE :search)';
81            $params['search'] = '%' . $search . '%';
82        }
83
84        if ($kycStatus !== null && $kycStatus !== '') {
85            $whereClauses[] = 'i.kyc_status = :kyc_status';
86            $params['kyc_status'] = $kycStatus;
87        }
88
89        if ($status !== null && $status !== '') {
90            $whereClauses[] = 'i.status = :status';
91            $params['status'] = $status;
92        }
93
94        $whereClause = count($whereClauses) > 0 ? 'WHERE ' . implode(' AND ', $whereClauses) : '';
95
96        // Count total
97        $countSql = "SELECT COUNT(*) FROM investors i LEFT JOIN accounts a ON i.investor_id = a.investor_id {$whereClause}";
98        $countStmt = $this->pdo->prepare($countSql);
99        $countStmt->execute($params);
100        $total = (int)$countStmt->fetchColumn();
101
102        // Get investors with account data
103        $sql = "
104            SELECT
105                i.investor_id as \"investorId\",
106                i.first_name as \"firstName\",
107                i.last_name as \"lastName\",
108                i.email,
109                i.phone,
110                i.kyc_status as \"kycStatus\",
111                i.status,
112                i.created_at as \"createdAt\",
113                a.account_id as \"accountId\",
114                a.account_number as \"accountNumber\",
115                a.balance::TEXT as balance,
116                a.status as \"accountStatus\",
117                u.user_id as \"userId\"
118            FROM investors i
119            LEFT JOIN accounts a ON i.investor_id = a.investor_id
120            LEFT JOIN users u ON i.investor_id = u.investor_id
121            {$whereClause}
122            ORDER BY i.created_at DESC
123            LIMIT :limit OFFSET :offset
124        ";
125
126        $stmt = $this->pdo->prepare($sql);
127
128        if ($stmt === false) {
129            throw new RuntimeException('Failed to prepare investors list statement');
130        }
131
132        foreach ($params as $key => $value) {
133            $stmt->bindValue($key, $value);
134        }
135        $stmt->bindValue('limit', $limit, PDO::PARAM_INT);
136        $stmt->bindValue('offset', $offset, PDO::PARAM_INT);
137
138        $stmt->execute();
139        $investors = $stmt->fetchAll(PDO::FETCH_ASSOC);
140
141        return [
142            'investors' => $investors,
143            'total' => $total,
144        ];
145    }
146
147    /**
148     * Get platform-wide AUM balance history (total balance across all accounts per day).
149     *
150     * @return list<array<mixed>>
151     */
152    public function getAumHistory(): array
153    {
154        // Exclude future-dated rows: the bulk data generator and a since-fixed
155        // interest projection have both left snapshot rows dated far in the
156        // future (e.g. 2099-01-01), which otherwise add a bogus data point and
157        // blow out the chart's date axis.
158        $sql = "
159            SELECT
160                snapshot_date::TEXT AS date,
161                SUM(balance)::TEXT AS value
162            FROM daily_account_balance
163            WHERE snapshot_date <= CURRENT_DATE
164            GROUP BY snapshot_date
165            ORDER BY snapshot_date
166        ";
167
168        $stmt = $this->pdo->query($sql);
169
170        if ($stmt === false) {
171            throw new RuntimeException('Failed to execute AUM history query');
172        }
173
174        $rows = [];
175        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
176            $rows[] = Row::from($row);
177        }
178
179        return $rows;
180    }
181
182    /**
183     * Get all transactions across the platform (paginated, filterable).
184     *
185     * @param int $page
186     * @param int $limit
187     * @param string|null $type
188     * @param string|null $search
189     * @param string|null $startDate
190     * @param string|null $endDate
191     *
192     * @return array{data: array<mixed>, total: int}
193     */
194    public function getAllTransactions(
195        int $page = 1,
196        int $limit = 25,
197        ?string $type = null,
198        ?string $search = null,
199        ?string $startDate = null,
200        ?string $endDate = null,
201    ): array {
202        $offset = ($page - 1) * $limit;
203        $conditions = [];
204        $params = [];
205
206        if ($type !== null && $type !== '') {
207            $conditions[] = 't.transaction_type = :type';
208            $params['type'] = $type;
209        }
210
211        if ($search !== null && $search !== '') {
212            $conditions[] = '(a.account_number ILIKE :search OR t.description ILIKE :search OR t.reference_number ILIKE :search OR i.first_name ILIKE :search OR i.last_name ILIKE :search)';
213            $params['search'] = '%' . $search . '%';
214        }
215
216        if ($startDate !== null && $startDate !== '') {
217            $conditions[] = 't.created_at >= :start_date';
218            $params['start_date'] = $startDate . ' 00:00:00';
219        }
220
221        if ($endDate !== null && $endDate !== '') {
222            $conditions[] = 't.created_at <= :end_date';
223            $params['end_date'] = $endDate . ' 23:59:59';
224        }
225
226        $whereClause = count($conditions) > 0 ? 'WHERE ' . implode(' AND ', $conditions) : '';
227
228        $countSql = "
229            SELECT COUNT(*)
230            FROM transactions t
231            JOIN accounts a ON t.account_id = a.account_id
232            JOIN investors i ON a.investor_id = i.investor_id
233            {$whereClause}
234        ";
235        $countStmt = $this->pdo->prepare($countSql);
236        $countStmt->execute($params);
237        $total = (int)$countStmt->fetchColumn();
238
239        // Pool balance is calculated over ALL transactions (unfiltered),
240        // then filters are applied to select which rows to display
241        $sql = "
242            WITH pool AS (
243                SELECT
244                    t.transaction_id,
245                    SUM(
246                        CASE WHEN t.transaction_type IN ('investment', 'transfer_in', 'interest')
247                            THEN t.amount
248                            ELSE -t.amount
249                        END
250                    ) OVER (ORDER BY t.created_at, t.transaction_id)::TEXT AS \"poolBalanceAfter\"
251                FROM transactions t
252                WHERE t.status = 'completed'
253            )
254            SELECT
255                t.transaction_id AS \"transactionId\",
256                t.account_id AS \"accountId\",
257                t.transaction_type AS \"transactionType\",
258                t.amount::TEXT AS amount,
259                t.balance_after::TEXT AS \"balanceAfter\",
260                t.description,
261                t.reference_number AS \"referenceNumber\",
262                t.created_at AS \"createdAt\",
263                t.status,
264                a.account_number AS \"accountNumber\",
265                i.investor_id AS \"investorId\",
266                i.first_name AS \"investorFirstName\",
267                i.last_name AS \"investorLastName\",
268                p.\"poolBalanceAfter\"
269            FROM transactions t
270            JOIN accounts a ON t.account_id = a.account_id
271            JOIN investors i ON a.investor_id = i.investor_id
272            JOIN pool p ON t.transaction_id = p.transaction_id
273            {$whereClause}
274            ORDER BY t.created_at DESC
275            LIMIT :limit OFFSET :offset
276        ";
277
278        $stmt = $this->pdo->prepare($sql);
279
280        if ($stmt === false) {
281            throw new RuntimeException('Failed to prepare transactions query');
282        }
283
284        foreach ($params as $key => $value) {
285            $stmt->bindValue($key, $value);
286        }
287        $stmt->bindValue('limit', $limit, PDO::PARAM_INT);
288        $stmt->bindValue('offset', $offset, PDO::PARAM_INT);
289        $stmt->execute();
290
291        return [
292            'data' => $stmt->fetchAll(PDO::FETCH_ASSOC),
293            'total' => $total,
294        ];
295    }
296
297    /**
298     * Lending-side activity feed: union of confirmed disbursements and loan
299     * payments. Replaces what used to surface as `loan_disbursement` rows in
300     * the Treasury transaction stream pre-FSC-96.
301     *
302     * @param int $page
303     * @param int $limit
304     * @return array{data: array<mixed>, total: int}
305     */
306    public function getLendingActivity(int $page = 1, int $limit = 25): array
307    {
308        $offset = ($page - 1) * $limit;
309
310        $countSql = "
311            SELECT (
312                (SELECT COUNT(*) FROM lending.disbursements WHERE status = 'disbursed')
313                + (SELECT COUNT(*) FROM lending.loan_payments)
314            ) AS total
315        ";
316        $countStmt = $this->pdo->query($countSql);
317        if ($countStmt === false) {
318            throw new RuntimeException('Failed to execute lending activity count');
319        }
320        $total = (int)$countStmt->fetchColumn();
321
322        $sql = "
323            SELECT * FROM (
324                SELECT
325                    'disbursement' AS \"activityType\",
326                    d.disbursement_id AS \"activityId\",
327                    d.loan_id AS \"loanId\",
328                    l.investor_id AS \"investorId\",
329                    i.first_name AS \"investorFirstName\",
330                    i.last_name AS \"investorLastName\",
331                    d.confirmed_amount::TEXT AS amount,
332                    d.disbursed_date::TEXT AS \"date\",
333                    d.bank_reference AS reference,
334                    d.disbursed_by AS \"actorUserId\",
335                    d.updated_at AS \"createdAt\"
336                FROM lending.disbursements d
337                JOIN lending.loans l ON l.loan_id = d.loan_id
338                JOIN investors i ON i.investor_id = l.investor_id
339                WHERE d.status = 'disbursed'
340                UNION ALL
341                SELECT
342                    'payment' AS \"activityType\",
343                    p.payment_id AS \"activityId\",
344                    p.loan_id AS \"loanId\",
345                    l.investor_id AS \"investorId\",
346                    i.first_name AS \"investorFirstName\",
347                    i.last_name AS \"investorLastName\",
348                    p.amount_paid::TEXT AS amount,
349                    p.payment_date::TEXT AS \"date\",
350                    p.payment_method AS reference,
351                    NULL::INTEGER AS \"actorUserId\",
352                    p.created_at AS \"createdAt\"
353                FROM lending.loan_payments p
354                JOIN lending.loans l ON l.loan_id = p.loan_id
355                JOIN investors i ON i.investor_id = l.investor_id
356            ) feed
357            ORDER BY \"createdAt\" DESC
358            LIMIT :limit OFFSET :offset
359        ";
360
361        $stmt = $this->pdo->prepare($sql);
362        if ($stmt === false) {
363            throw new RuntimeException('Failed to prepare lending activity query');
364        }
365        $stmt->bindValue('limit', $limit, PDO::PARAM_INT);
366        $stmt->bindValue('offset', $offset, PDO::PARAM_INT);
367        $stmt->execute();
368
369        return [
370            'data' => $stmt->fetchAll(PDO::FETCH_ASSOC),
371            'total' => $total,
372        ];
373    }
374
375    /**
376     * Get detailed investor info for admin view.
377     *
378     * @param int $investorId
379     *
380     * @return array<mixed>|null
381     */
382    public function getInvestorDetail(int $investorId): ?array
383    {
384        $sql = '
385            SELECT
386                i.investor_id as "investorId",
387                i.first_name as "firstName",
388                i.last_name as "lastName",
389                i.email,
390                i.phone,
391                i.date_of_birth as "dateOfBirth",
392                i.address_line1 as "addressLine1",
393                i.address_line2 as "addressLine2",
394                i.city,
395                i.state,
396                i.zip_code as "zipCode",
397                i.country,
398                i.kyc_status as "kycStatus",
399                i.status,
400                i.created_at as "createdAt",
401                i.updated_at as "updatedAt",
402                a.account_id as "accountId",
403                a.account_number as "accountNumber",
404                a.balance::TEXT as balance,
405                a.available_balance::TEXT as "availableBalance",
406                a.available_for_loan::TEXT as "availableForLoan",
407                COALESCE(a.interest_rate, get_loan_config(\'account_yield_rate\'))::TEXT as "interestRate",
408                a.loan_to_value_ratio::TEXT as "loanToValueRatio",
409                a.status as "accountStatus",
410                a.opened_date as "openedDate",
411                u.user_id as "userId",
412                u.username,
413                u.role
414            FROM investors i
415            LEFT JOIN accounts a ON i.investor_id = a.investor_id
416            LEFT JOIN users u ON i.investor_id = u.investor_id
417            WHERE i.investor_id = :investor_id
418        ';
419
420        $stmt = $this->pdo->prepare($sql);
421
422        if ($stmt === false) {
423            throw new RuntimeException('Failed to prepare investor detail statement');
424        }
425
426        $stmt->execute(['investor_id' => $investorId]);
427
428        $row = $stmt->fetch(PDO::FETCH_ASSOC);
429
430        return is_array($row) ? $row : null;
431    }
432}