Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.35% covered (warning)
82.35%
42 / 51
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
TransactionService
82.35% covered (warning)
82.35%
42 / 51
33.33% covered (danger)
33.33%
1 / 3
22.20
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
 createTransaction
83.67% covered (warning)
83.67%
41 / 49
0.00% covered (danger)
0.00%
0 / 1
19.41
 getValidTypes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Transaction\Service;
6
7use App\Domain\Exception\ConflictException;
8use App\Domain\Exception\NotFoundException;
9use App\Domain\Exception\ValidationException;
10use App\Domain\Loan\Repository\LoanRepository;
11use App\Domain\Transaction\Data\TransactionData;
12use App\Domain\Transaction\Repository\TransactionRepository;
13
14use function in_array;
15
16/**
17 * Service for creating and managing transactions.
18 */
19final readonly class TransactionService
20{
21    private const VALID_TYPES = [
22        'investment',
23        'withdrawal',
24        'transfer_in',
25        'transfer_out',
26        'interest',
27        'fee',
28        'loan_payment',
29    ];
30
31    public function __construct(
32        private TransactionRepository $repository,
33        private LoanRepository $loanRepository,
34    ) {}
35
36    /**
37     * Create a new transaction.
38     *
39     * @param int $accountId The account ID
40     * @param string $transactionType The type of transaction
41     * @param string $amount The transaction amount (positive value)
42     * @param string|null $description Optional description
43     *
44     * @throws ConflictException If account is closed or frozen
45     * @throws NotFoundException If account does not exist
46     * @throws ValidationException If parameters fail validation
47     *
48     * @return TransactionData The created transaction
49     */
50    public function createTransaction(
51        int $accountId,
52        string $transactionType,
53        string $amount,
54        ?string $description = null,
55    ): TransactionData {
56        // Validate transaction type
57        if (!in_array($transactionType, self::VALID_TYPES, true)) {
58            throw new ValidationException(
59                'Invalid transaction type. Must be one of: ' . implode(', ', self::VALID_TYPES),
60            );
61        }
62
63        // Validate amount is positive
64        $amountFloat = (float)$amount;
65        if ($amountFloat <= 0) {
66            throw new ValidationException('Amount must be greater than zero');
67        }
68
69        // Validate account exists
70        if (!$this->repository->accountExists($accountId)) {
71            throw new NotFoundException("Account with ID {$accountId} not found");
72        }
73
74        // FSC-98: block new investments while the investor has any loan in
75        // a non-terminal state. Keeps the secured-loan invariant — loaned
76        // funds can't be re-deposited as additional collateral. super_admin
77        // may toggle the allow_investment_with_active_loans config key for
78        // specific carve-outs without a code change; the default is false.
79        if (
80            $transactionType === 'investment'
81            && !$this->loanRepository->getConfigBool('allow_investment_with_active_loans')
82            && $this->loanRepository->investorHasBlockingLoan($accountId)
83        ) {
84            throw new ConflictException(
85                'Cannot record new investment while investor has a pending, approved, or active loan',
86            );
87        }
88
89        // FSC-49: block withdrawals while the investor has any loan in a
90        // non-terminal state. Enforces MPA §5.3 — investors with outstanding
91        // loans cannot pull funds. super_admin may toggle the
92        // allow_withdrawal_with_active_loans config key for refund/legal
93        // carve-outs without a code change; the default is false.
94        if (
95            $transactionType === 'withdrawal'
96            && !$this->loanRepository->getConfigBool('allow_withdrawal_with_active_loans')
97            && $this->loanRepository->investorHasBlockingLoan($accountId)
98        ) {
99            throw new ConflictException(
100                'Cannot withdraw funds while investor has a pending, approved, or active loan (MPA §5.3)',
101            );
102        }
103
104        // FSC-48: enforce the 90-day investment lock-up from MPA §5.2.
105        // Funds attributable to investments made within the lock-up window
106        // cannot be withdrawn. Loans are explicitly NOT subject — this
107        // check only fires on `withdrawal` transactions. super_admin may
108        // flip allow_withdrawal_during_lockup for hardship/legal carve-outs.
109        if (
110            $transactionType === 'withdrawal'
111            && !$this->loanRepository->getConfigBool('allow_withdrawal_during_lockup')
112        ) {
113            $lockupDays = (int)($this->loanRepository->getConfig()['lockup_period_days'] ?? '90');
114            $lockedBalance = $this->repository->getLockedInvestmentBalance($accountId, $lockupDays);
115            $currentBalance = $this->repository->getAccountAvailableBalance($accountId);
116            $withdrawable = max(0.0, (float)$currentBalance - (float)$lockedBalance);
117
118            if ($amountFloat > $withdrawable) {
119                throw new ConflictException(sprintf(
120                    'Withdrawal exceeds the unlocked balance. $%s is currently locked under the %d-day investment lock-up (MPA §5.2); only $%s is available to withdraw.',
121                    number_format((float)$lockedBalance, 2),
122                    $lockupDays,
123                    number_format($withdrawable, 2),
124                ));
125            }
126        }
127
128        // Check account status (must be active or pending for investments)
129        $accountStatus = $this->repository->getAccountStatus($accountId);
130
131        if ($accountStatus === 'closed') {
132            throw new ConflictException('Cannot create transaction on a closed account');
133        }
134
135        if ($accountStatus === 'frozen' && $transactionType !== 'interest') {
136            throw new ConflictException('Cannot create transaction on a frozen account');
137        }
138
139        // For withdrawals, check sufficient balance
140        if (in_array($transactionType, ['withdrawal', 'transfer_out', 'fee', 'loan_payment'], true)) {
141            $availableBalance = $this->repository->getAccountAvailableBalance($accountId);
142            if ($amountFloat > (float)$availableBalance) {
143                throw new ValidationException('Insufficient available balance for this transaction');
144            }
145        }
146
147        // Create the transaction
148        return $this->repository->create(
149            accountId: $accountId,
150            transactionType: $transactionType,
151            amount: $amount,
152            description: $description,
153        );
154    }
155
156    /**
157     * Get valid transaction types.
158     *
159     * @return array<string>
160     */
161    public function getValidTypes(): array
162    {
163        return self::VALID_TYPES;
164    }
165}