Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
46.00% covered (danger)
46.00%
46 / 100
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
AdminRepository
46.00% covered (danger)
46.00%
46 / 100
14.29% covered (danger)
14.29%
1 / 7
204.48
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
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 getInvestorsList
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
10
 getAumHistory
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getAllTransactions
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
156
 getInvestorDetail
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getInvestorAllocations
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Admin\Repository;
6
7use App\Domain\Admin\Data\AdminStatsData;
8use PDO;
9use RuntimeException;
10
11use function count;
12
13/**
14 * Repository for admin-specific data access operations.
15 */
16final class AdminRepository
17{
18    public function __construct(
19        private readonly PDO $pdo
20    ) {}
21
22    /**
23     * Get platform-wide statistics for admin dashboard.
24     */
25    public function getStats(): AdminStatsData
26    {
27        $sql = "
28            SELECT
29                (SELECT COUNT(*) FROM investors) as \"totalInvestors\",
30                (SELECT COUNT(*) FROM investors WHERE status = 'active') as \"activeInvestors\",
31                (SELECT COUNT(*) FROM investors WHERE kyc_status = 'pending') as \"pendingKyc\",
32                (SELECT COUNT(*) FROM accounts) as \"totalAccounts\",
33                (SELECT COUNT(*) FROM accounts WHERE status = 'active') as \"activeAccounts\",
34                (SELECT COUNT(*) FROM accounts WHERE status = 'pending') as \"pendingAccounts\",
35                (SELECT COALESCE(SUM(balance), 0)::TEXT FROM accounts) as \"totalAum\",
36                (SELECT COALESCE(SUM(available_for_loan), 0)::TEXT FROM accounts) as \"totalAvailableForLoan\"
37        ";
38
39        $stmt = $this->pdo->query($sql);
40
41        if ($stmt === false) {
42            throw new RuntimeException('Failed to execute stats query');
43        }
44
45        $row = $stmt->fetch(PDO::FETCH_ASSOC);
46
47        return new AdminStatsData($row);
48    }
49
50    /**
51     * Get paginated list of all investors with account info.
52     *
53     * @param int $page
54     * @param int $limit
55     * @param string|null $search Search by name or email
56     * @param string|null $kycStatus Filter by KYC status
57     * @param string|null $status Filter by investor status
58     *
59     * @return array{investors: array<int, array<string, mixed>>, total: int}
60     */
61    public function getInvestorsList(
62        int $page = 1,
63        int $limit = 20,
64        ?string $search = null,
65        ?string $kycStatus = null,
66        ?string $status = null,
67    ): array {
68        $offset = ($page - 1) * $limit;
69        $params = [];
70        $whereClauses = [];
71
72        // Build WHERE clauses
73        if ($search !== null && $search !== '') {
74            $whereClauses[] = '(i.first_name ILIKE :search OR i.last_name ILIKE :search OR i.email ILIKE :search)';
75            $params['search'] = '%' . $search . '%';
76        }
77
78        if ($kycStatus !== null && $kycStatus !== '') {
79            $whereClauses[] = 'i.kyc_status = :kyc_status';
80            $params['kyc_status'] = $kycStatus;
81        }
82
83        if ($status !== null && $status !== '') {
84            $whereClauses[] = 'i.status = :status';
85            $params['status'] = $status;
86        }
87
88        $whereClause = count($whereClauses) > 0 ? 'WHERE ' . implode(' AND ', $whereClauses) : '';
89
90        // Count total
91        $countSql = "SELECT COUNT(*) FROM investors i {$whereClause}";
92        $countStmt = $this->pdo->prepare($countSql);
93        $countStmt->execute($params);
94        $total = (int)$countStmt->fetchColumn();
95
96        // Get investors with account data
97        $sql = "
98            SELECT
99                i.investor_id as \"investorId\",
100                i.first_name as \"firstName\",
101                i.last_name as \"lastName\",
102                i.email,
103                i.phone,
104                i.kyc_status as \"kycStatus\",
105                i.status,
106                i.created_at as \"createdAt\",
107                a.account_id as \"accountId\",
108                a.account_number as \"accountNumber\",
109                a.balance::TEXT as balance,
110                a.status as \"accountStatus\",
111                u.user_id as \"userId\"
112            FROM investors i
113            LEFT JOIN accounts a ON i.investor_id = a.investor_id
114            LEFT JOIN users u ON i.investor_id = u.investor_id
115            {$whereClause}
116            ORDER BY i.created_at DESC
117            LIMIT :limit OFFSET :offset
118        ";
119
120        $stmt = $this->pdo->prepare($sql);
121
122        if ($stmt === false) {
123            throw new RuntimeException('Failed to prepare investors list statement');
124        }
125
126        foreach ($params as $key => $value) {
127            $stmt->bindValue($key, $value);
128        }
129        $stmt->bindValue('limit', $limit, PDO::PARAM_INT);
130        $stmt->bindValue('offset', $offset, PDO::PARAM_INT);
131
132        $stmt->execute();
133        $investors = $stmt->fetchAll(PDO::FETCH_ASSOC);
134
135        return [
136            'investors' => $investors,
137            'total' => $total,
138        ];
139    }
140
141    /**
142     * Get platform-wide AUM balance history (total balance across all accounts per day).
143     *
144     * @return array<int, array{date: string, value: string}>
145     */
146    public function getAumHistory(): array
147    {
148        $sql = "
149            SELECT
150                DATE(ft.created_at)::TEXT AS date,
151                SUM(a.current_value)::TEXT AS value
152            FROM fund_transactions ft
153            JOIN allocations a ON ft.user_id = a.user_id AND ft.fund_id = a.fund_id
154            WHERE ft.status = 'Completed'
155            GROUP BY DATE(ft.created_at)
156            ORDER BY DATE(ft.created_at)
157        ";
158
159        $stmt = $this->pdo->query($sql);
160
161        if ($stmt === false) {
162            throw new RuntimeException('Failed to execute AUM history query');
163        }
164
165        return $stmt->fetchAll(PDO::FETCH_ASSOC);
166    }
167
168    /**
169     * Get all transactions across the platform (paginated, filterable).
170     *
171     * @param int $page
172     * @param int $limit
173     * @param string|null $type
174     * @param string|null $search
175     * @param string|null $startDate
176     * @param string|null $endDate
177     *
178     * @return array{data: array<int, array<string, mixed>>, total: int}
179     */
180    public function getAllTransactions(
181        int $page = 1,
182        int $limit = 25,
183        ?string $type = null,
184        ?string $search = null,
185        ?string $startDate = null,
186        ?string $endDate = null,
187    ): array {
188        $offset = ($page - 1) * $limit;
189        $conditions = [];
190        $params = [];
191
192        if ($type !== null && $type !== '') {
193            $conditions[] = 't.transaction_type = :type';
194            $params['type'] = $type;
195        }
196
197        if ($search !== null && $search !== '') {
198            $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)';
199            $params['search'] = '%' . $search . '%';
200        }
201
202        if ($startDate !== null && $startDate !== '') {
203            $conditions[] = 't.created_at >= :start_date';
204            $params['start_date'] = $startDate . ' 00:00:00';
205        }
206
207        if ($endDate !== null && $endDate !== '') {
208            $conditions[] = 't.created_at <= :end_date';
209            $params['end_date'] = $endDate . ' 23:59:59';
210        }
211
212        $whereClause = count($conditions) > 0 ? 'WHERE ' . implode(' AND ', $conditions) : '';
213
214        $countSql = "
215            SELECT COUNT(*)
216            FROM transactions t
217            JOIN accounts a ON t.account_id = a.account_id
218            JOIN investors i ON a.investor_id = i.investor_id
219            {$whereClause}
220        ";
221        $countStmt = $this->pdo->prepare($countSql);
222        $countStmt->execute($params);
223        $total = (int)$countStmt->fetchColumn();
224
225        // Pool balance is calculated over ALL transactions (unfiltered),
226        // then filters are applied to select which rows to display
227        $sql = "
228            WITH pool AS (
229                SELECT
230                    t.transaction_id,
231                    SUM(
232                        CASE WHEN t.transaction_type IN ('deposit', 'transfer_in', 'interest', 'loan_disbursement')
233                            THEN t.amount
234                            ELSE -t.amount
235                        END
236                    ) OVER (ORDER BY t.created_at, t.transaction_id)::TEXT AS \"poolBalanceAfter\"
237                FROM transactions t
238                WHERE t.status = 'completed'
239            )
240            SELECT
241                t.transaction_id AS \"transactionId\",
242                t.account_id AS \"accountId\",
243                t.transaction_type AS \"transactionType\",
244                t.amount::TEXT AS amount,
245                t.balance_after::TEXT AS \"balanceAfter\",
246                t.description,
247                t.reference_number AS \"referenceNumber\",
248                t.created_at AS \"createdAt\",
249                t.status,
250                a.account_number AS \"accountNumber\",
251                i.first_name AS \"investorFirstName\",
252                i.last_name AS \"investorLastName\",
253                p.\"poolBalanceAfter\"
254            FROM transactions t
255            JOIN accounts a ON t.account_id = a.account_id
256            JOIN investors i ON a.investor_id = i.investor_id
257            JOIN pool p ON t.transaction_id = p.transaction_id
258            {$whereClause}
259            ORDER BY t.created_at DESC
260            LIMIT :limit OFFSET :offset
261        ";
262
263        $stmt = $this->pdo->prepare($sql);
264
265        if ($stmt === false) {
266            throw new RuntimeException('Failed to prepare transactions query');
267        }
268
269        foreach ($params as $key => $value) {
270            $stmt->bindValue($key, $value);
271        }
272        $stmt->bindValue('limit', $limit, PDO::PARAM_INT);
273        $stmt->bindValue('offset', $offset, PDO::PARAM_INT);
274        $stmt->execute();
275
276        return [
277            'data' => $stmt->fetchAll(PDO::FETCH_ASSOC),
278            'total' => $total,
279        ];
280    }
281
282    /**
283     * Get detailed investor info for admin view.
284     *
285     * @param int $investorId
286     *
287     * @return array<string, mixed>|null
288     */
289    public function getInvestorDetail(int $investorId): ?array
290    {
291        $sql = '
292            SELECT
293                i.investor_id as "investorId",
294                i.first_name as "firstName",
295                i.last_name as "lastName",
296                i.email,
297                i.phone,
298                i.date_of_birth as "dateOfBirth",
299                i.address_line1 as "addressLine1",
300                i.address_line2 as "addressLine2",
301                i.city,
302                i.state,
303                i.zip_code as "zipCode",
304                i.country,
305                i.kyc_status as "kycStatus",
306                i.status,
307                i.created_at as "createdAt",
308                i.updated_at as "updatedAt",
309                a.account_id as "accountId",
310                a.account_number as "accountNumber",
311                a.balance::TEXT as balance,
312                a.available_balance::TEXT as "availableBalance",
313                a.available_for_loan::TEXT as "availableForLoan",
314                a.interest_rate::TEXT as "interestRate",
315                a.loan_to_value_ratio::TEXT as "loanToValueRatio",
316                a.status as "accountStatus",
317                a.opened_date as "openedDate",
318                u.user_id as "userId",
319                u.username,
320                u.role
321            FROM investors i
322            LEFT JOIN accounts a ON i.investor_id = a.investor_id
323            LEFT JOIN users u ON i.investor_id = u.investor_id
324            WHERE i.investor_id = :investor_id
325        ';
326
327        $stmt = $this->pdo->prepare($sql);
328
329        if ($stmt === false) {
330            throw new RuntimeException('Failed to prepare investor detail statement');
331        }
332
333        $stmt->execute(['investor_id' => $investorId]);
334
335        $row = $stmt->fetch(PDO::FETCH_ASSOC);
336
337        return $row ?: null;
338    }
339
340    /**
341     * Get current allocations for an investor.
342     *
343     * @param int $investorId
344     * @return array<int, array<string, mixed>>
345     */
346    public function getInvestorAllocations(int $investorId): array
347    {
348        $sql = '
349            SELECT
350                a.id::TEXT AS "id",
351                a.fund_id::TEXT AS "fundId",
352                f.name AS "fundName",
353                a.invested_amount::TEXT AS "investedAmount",
354                a.current_value::TEXT AS "currentValue",
355                a.units::TEXT AS "units",
356                a.created_at AS "createdAt"
357            FROM allocations a
358            JOIN users u ON a.user_id::TEXT = u.user_id::TEXT
359            JOIN investors i ON i.investor_id = u.investor_id
360            JOIN funds f ON f.id = a.fund_id
361            WHERE i.investor_id = :investor_id
362            ORDER BY a.created_at DESC
363        ';
364
365        $stmt = $this->pdo->prepare($sql);
366        if ($stmt === false) {
367            throw new RuntimeException('Failed to prepare investor allocations statement');
368        }
369
370        $stmt->execute(['investor_id' => $investorId]);
371        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
372
373        return $rows ?: [];
374    }
375}