Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.44% covered (warning)
73.44%
47 / 64
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
FundingRequestRepository
73.44% covered (warning)
73.44%
47 / 64
14.29% covered (danger)
14.29%
1 / 7
22.42
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
 create
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
3.01
 findByInvestorId
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 listPending
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 findById
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 countByStatus
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 updateStatus
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Funding\Repository;
6
7use App\Domain\Funding\Data\FundingRequestData;
8use App\Support\Row;
9use PDO;
10use RuntimeException;
11
12/**
13 * Persists and reads investor-initiated deposit/withdrawal requests (FSC-21).
14 */
15final readonly class FundingRequestRepository
16{
17    private const string SELECT_COLUMNS = '
18        funding_request_id AS "fundingRequestId",
19        investor_id        AS "investorId",
20        account_id         AS "accountId",
21        request_type       AS "requestType",
22        amount,
23        note,
24        status,
25        created_at         AS "createdAt"
26    ';
27
28    /**
29     * Admin-queue projection: the investor-facing columns plus the investor
30     * identity (joined) and the review timestamp, so an admin can action a
31     * request without a second lookup.
32     */
33    private const string ADMIN_SELECT_COLUMNS = '
34        fr.funding_request_id AS "fundingRequestId",
35        fr.investor_id        AS "investorId",
36        fr.account_id         AS "accountId",
37        fr.request_type       AS "requestType",
38        fr.amount,
39        fr.note,
40        fr.status,
41        fr.created_at         AS "createdAt",
42        fr.reviewed_at        AS "reviewedAt",
43        i.first_name || \' \' || i.last_name AS "investorName",
44        i.email               AS "investorEmail"
45    ';
46
47    public function __construct(
48        private PDO $pdo,
49    ) {}
50
51    public function create(int $investorId, int $accountId, string $type, string $amount, ?string $note): FundingRequestData
52    {
53        $stmt = $this->pdo->prepare(
54            'INSERT INTO funding_requests (investor_id, account_id, request_type, amount, note)
55                 VALUES (:investor_id, :account_id, :request_type, :amount, :note)
56             RETURNING ' . self::SELECT_COLUMNS,
57        );
58        if ($stmt === false) {
59            throw new RuntimeException('Failed to prepare statement');
60        }
61
62        $stmt->execute([
63            'investor_id' => $investorId,
64            'account_id' => $accountId,
65            'request_type' => $type,
66            'amount' => $amount,
67            'note' => $note,
68        ]);
69
70        $row = $stmt->fetch(PDO::FETCH_ASSOC);
71        if (!is_array($row)) {
72            throw new RuntimeException('Failed to read created funding request');
73        }
74
75        return FundingRequestData::fromRow($row);
76    }
77
78    /**
79     * @param int $investorId
80     * @return list<FundingRequestData>
81     */
82    public function findByInvestorId(int $investorId): array
83    {
84        $stmt = $this->pdo->prepare(
85            'SELECT ' . self::SELECT_COLUMNS . '
86             FROM funding_requests
87             WHERE investor_id = :investor_id
88             ORDER BY created_at DESC',
89        );
90        if ($stmt === false) {
91            throw new RuntimeException('Failed to prepare statement');
92        }
93        $stmt->execute(['investor_id' => $investorId]);
94
95        $requests = [];
96        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
97            $requests[] = FundingRequestData::fromRow(Row::from($row));
98        }
99
100        return $requests;
101    }
102
103    /**
104     * Pending requests awaiting admin action, oldest first (queue order).
105     * Includes both deposit ("Invest") and withdrawal requests.
106     *
107     * @return list<FundingRequestData>
108     */
109    public function listPending(): array
110    {
111        $stmt = $this->pdo->query(
112            'SELECT ' . self::ADMIN_SELECT_COLUMNS . '
113             FROM funding_requests fr
114             JOIN investors i ON i.investor_id = fr.investor_id
115             WHERE fr.status = \'pending\'
116             ORDER BY fr.created_at ASC, fr.funding_request_id ASC',
117        );
118        if ($stmt === false) {
119            throw new RuntimeException('Failed to execute query');
120        }
121
122        $requests = [];
123        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
124            $requests[] = FundingRequestData::fromRow(Row::from($row));
125        }
126
127        return $requests;
128    }
129
130    public function findById(int $fundingRequestId): ?FundingRequestData
131    {
132        $stmt = $this->pdo->prepare(
133            'SELECT ' . self::ADMIN_SELECT_COLUMNS . '
134             FROM funding_requests fr
135             JOIN investors i ON i.investor_id = fr.investor_id
136             WHERE fr.funding_request_id = :id',
137        );
138        if ($stmt === false) {
139            throw new RuntimeException('Failed to prepare statement');
140        }
141        $stmt->execute(['id' => $fundingRequestId]);
142
143        $row = $stmt->fetch(PDO::FETCH_ASSOC);
144
145        return is_array($row) ? FundingRequestData::fromRow(Row::from($row)) : null;
146    }
147
148    public function countByStatus(string $status): int
149    {
150        $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM funding_requests WHERE status = :status');
151        if ($stmt === false) {
152            throw new RuntimeException('Failed to prepare statement');
153        }
154        $stmt->execute(['status' => $status]);
155
156        return (int)$stmt->fetchColumn();
157    }
158
159    /**
160     * Transition a request to a terminal status and stamp who/when reviewed it.
161     * Caller validates the state transition.
162     * @param int $fundingRequestId
163     * @param string $status
164     * @param int $reviewedBy
165     */
166    public function updateStatus(int $fundingRequestId, string $status, int $reviewedBy): void
167    {
168        $stmt = $this->pdo->prepare(
169            'UPDATE funding_requests
170                SET status = :status,
171                    reviewed_at = CURRENT_TIMESTAMP,
172                    reviewed_by = :reviewed_by
173             WHERE funding_request_id = :id',
174        );
175        if ($stmt === false) {
176            throw new RuntimeException('Failed to prepare statement');
177        }
178        $stmt->execute([
179            'status' => $status,
180            'reviewed_by' => $reviewedBy,
181            'id' => $fundingRequestId,
182        ]);
183    }
184}