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