Repository class

I have a controller that works well :

<?php namespace PHPMaker2026\serverapi; use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\Routing\Annotation\Route;use Doctrine\ORM\EntityManagerInterface; /*** WithdrawalController** Handles all withdrawal logic:* - Eligibility checks (waiting period, quarterly limit, balance)* - OTP verification before initiation* - Withdrawal initiation → sends to fa API* - Withdrawal history** All rules are driven by system_config — no hardcoded values.*/class WithdrawalController{private const BCSCALE = 2; // ── Config cache (shared with ContributionController pattern) ─────────────private static array $_configCache = [];private static int $_configCachedAt = 0;private const CONFIG_CACHE_TTL = 300; private function getConfig(EntityManagerInterface $em, string $key, string $default = ''): string{$now = time();if (empty(self::$_configCache) || ($now - self::$_configCachedAt) > self::CONFIG_CACHE_TTL) {try {$rows = $em->getConnection()->executeQuery('SELECT config_key, config_value FROM system_config')->fetchAllAssociative();self::$_configCache = array_column($rows, 'config_value', 'config_key');self::$_configCachedAt = $now;} catch (\Exception $e) {error_log('system_config fetch error: ' . $e->getMessage());return $default;}}return self::$_configCache[$key] ?? $default;} /*** Check withdrawal eligibility for a contributor* Route: GET /api/withdrawal/eligibility/{id}** Returns:* eligible: bool* reason: string (if not eligible)* contingent_balance: string* withdrawals_this_quarter: int* max_per_quarter: int* waiting_months: int* first_contribution_date: string|null*/#[Route('/api/withdrawal/eligibility/{id}', methods: ['GET', 'OPTIONS'], name: 'withdrawal_eligibility')]public function eligibility(string $id, EntityManagerInterface $entityManager): JsonResponse{$contributorId = (int)$id;if (!$contributorId) {return new JsonResponse(['success' => false, 'message' => 'Invalid contributor ID'], 400);} $conn = $entityManager->getConnection(); // ── Read config ─────────────────────────────────────────────────────── $waitingMonths = (int)$this->getConfig($entityManager, 'withdrawal_waiting_months', '3'); $maxPerQuarter = (int)$this->getConfig($entityManager, 'withdrawal_max_per_quarter', '2'); // ── Get first processed contribution date ───────────────────────────── $firstContrib = $conn->executeQuery( "SELECT MIN(created_on) as first_date, COALESCE(SUM(amount), '0.00') as total FROM contribution WHERE contributor_id = :id AND status = 'processed'", \['id' => $contributorId\] )->fetchAssociative(); $firstDate = $firstContrib\['first_date'\] ?? null; $total = bcadd((string)($firstContrib\['total'\] ?? '0'), '0', self::BCSCALE); // Contingent balance = total × contingent_ratio $contingentRatio = $this->getConfig($entityManager, 'contingent_ratio', '0.50'); $contingent = bcmul($total, $contingentRatio, self::BCSCALE); // ── Rule 1: Must have at least one contribution ─────────────────────── if (!$firstDate) { return new JsonResponse(\[ 'success' => true, 'eligible' => false, 'reason' => 'No contributions found. You must make at least one contribution before requesting a withdrawal.', 'contingent_balance' => $contingent, 'withdrawals_this_quarter' => 0, 'max_per_quarter' => $maxPerQuarter, 'waiting_months' => $waitingMonths, 'first_contribution_date' => null, \]); } // ── Rule 2: Waiting period ──────────────────────────────────────────── $firstDateTime = new \\DateTime($firstDate); $now = new \\DateTime(); $monthsElapsed = (int)$firstDateTime->diff($now)->m + ((int)$firstDateTime->diff($now)->y \* 12); if ($monthsElapsed < $waitingMonths) { $eligible_from = (clone $firstDateTime)->modify("+{$waitingMonths} months"); return new JsonResponse(\[ 'success' => true, 'eligible' => false, 'reason' => "Withdrawal is not available yet. You must wait {$waitingMonths} months from your first contribution. Eligible from: " . $eligible_from->format('d M Y') . '.', 'contingent_balance' => $contingent, 'withdrawals_this_quarter' => 0, 'max_per_quarter' => $maxPerQuarter, 'waiting_months' => $waitingMonths, 'first_contribution_date' => $firstDateTime->format('Y-m-d'), 'eligible_from' => $eligible_from->format('Y-m-d'), \]); } // ── Rule 3: Quarterly limit ─────────────────────────────────────────── // Determine current quarter start/end $month = (int)$now->format('n'); $year = (int)$now->format('Y'); $quarterStart = (int)ceil($month / 3) \* 3 - 2; // 1, 4, 7, or 10 $quarterEnd = $quarterStart + 2; $qStart = sprintf('%d-%02d-01', $year, $quarterStart); $qEnd = sprintf('%d-%02d-%02d', $year, $quarterEnd, cal_days_in_month(CAL_GREGORIAN, $quarterEnd, $year)); $withdrawalsThisQuarter = (int)$conn->executeQuery( "SELECT COUNT(\*) FROM withdrawal WHERE contributor_id = :id AND status IN ('pending','approved') AND initiated_on BETWEEN :start AND :end", \['id' => $contributorId, 'start' => $qStart . ' 00:00:00', 'end' => $qEnd . ' 23:59:59'\] )->fetchOne(); if ($withdrawalsThisQuarter >= $maxPerQuarter) { return new JsonResponse(\[ 'success' => true, 'eligible' => false, 'reason' => "You have already made {$withdrawalsThisQuarter} withdrawal(s) this quarter. Maximum allowed is {$maxPerQuarter} per quarter.", 'contingent_balance' => $contingent, 'withdrawals_this_quarter' => $withdrawalsThisQuarter, 'max_per_quarter' => $maxPerQuarter, 'waiting_months' => $waitingMonths, 'first_contribution_date' => $firstDateTime->format('Y-m-d'), \]); } // ── Rule 4: Must have contingent balance ────────────────────────────── if (bccomp($contingent, '0.00', self::BCSCALE) <= 0) { return new JsonResponse(\[ 'success' => true, 'eligible' => false, 'reason' => 'Your contingent balance is ₦0.00. No funds available for withdrawal.', 'contingent_balance' => $contingent, 'withdrawals_this_quarter' => $withdrawalsThisQuarter, 'max_per_quarter' => $maxPerQuarter, 'waiting_months' => $waitingMonths, 'first_contribution_date' => $firstDateTime->format('Y-m-d'), \]); } // ── All rules passed ────────────────────────────────────────────────── return new JsonResponse(\[ 'success' => true, 'eligible' => true, 'contingent_balance' => $contingent, 'withdrawals_this_quarter' => $withdrawalsThisQuarter, 'max_per_quarter' => $maxPerQuarter, 'waiting_months' => $waitingMonths, 'first_contribution_date' => $firstDateTime->format('Y-m-d'), 'remaining_this_quarter' => $maxPerQuarter - $withdrawalsThisQuarter, \]); } /*** Get Pencom-approved withdrawal reasons (active only)* Route: GET /api/withdrawal/reasons*/#[Route('/api/withdrawal/reasons', methods: ['GET', 'OPTIONS'], name: 'withdrawal_reasons')]public function reasons(EntityManagerInterface $entityManager): JsonResponse{try {$rows = $entityManager->getConnection()->executeQuery("SELECT id, reason_name, reason_code, descriptionFROM withdrawal_reasonWHERE status = 'active'ORDER BY sort_order ASC, reason_name ASC")->fetchAllAssociative();return new JsonResponse(['success' => true, 'data' => $rows]);} catch (\Exception $e) {return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);}} /*** Initiate a withdrawal request* Route: POST /api/withdrawal/initiate** Body: { contributor_id, amount, reason_id, reason_note, otp }** Flow:* 1. Re-validate eligibility (server-side — never trust client)* 2. Verify OTP* 3. Create withdrawal record (status=pending)* 4. Send to fa API (DUMMY — replace when fa API available)* 5. Update record with fa reference* 6. Send SMS notification (DUMMY)* 7. Audit log*/#[Route('/api/withdrawal/initiate', methods: ['POST', 'OPTIONS'], name: 'withdrawal_initiate')]public function initiate(EntityManagerInterface $entityManager): JsonResponse{$data = json_decode(file_get_contents('php://input'), true) ?? [];$contributorId = (int)($data['contributor_id'] ?? 0);$otp = trim($data['otp'] ?? '');$reasonId = (int)($data['reason_id'] ?? 0);$reasonNote = trim($data['reason_note'] ?? ''); // Amount validation $rawAmount = preg_replace('/\[^0-9.\]/', '', (string)($data\['amount'\] ?? '0')); $amount = bcadd($rawAmount, '0', self::BCSCALE); if (!$contributorId) { return new JsonResponse(\['success' => false, 'message' => 'contributor_id is required'\], 400); } if (!$reasonId) { return new JsonResponse(\['success' => false, 'message' => 'Please select a withdrawal reason'\], 400); } if (bccomp($amount, '0.00', self::BCSCALE) <= 0) { return new JsonResponse(\['success' => false, 'message' => 'Invalid withdrawal amount'\], 400); } // ── OTP Verification (DUMMY — replace with real SMS OTP) ───────────── // TODO: Verify OTP against stored OTP for this contributor's phone if ($otp !== '123456') { return new JsonResponse(\['success' => false, 'message' => 'Invalid OTP. Please try again.'\], 400); } $conn = $entityManager->getConnection(); $audit = new AuditController(); // ── Re-validate eligibility server-side ─────────────────────────────── $eligibilityResponse = $this->eligibility((string)$contributorId, $entityManager); $eligibilityData = json_decode($eligibilityResponse->getContent(), true); if (!$eligibilityData\['eligible'\]) { return new JsonResponse(\[ 'success' => false, 'message' => $eligibilityData\['reason'\] \], 403); } // ── Validate amount against contingent balance ──────────────────────── $contingent = $eligibilityData\['contingent_balance'\]; if (bccomp($amount, $contingent, self::BCSCALE) > 0) { return new JsonResponse(\[ 'success' => false, 'message' => 'Withdrawal amount exceeds your contingent balance of ₦' . number_format((float)$contingent, 2) \], 400); } // ── Get contributor details for fa API ─────────────────────────────── $contributor = $conn->executeQuery( "SELECT c.username, c.phone, c.rsa_pin, c.fa_code, cp.first_name, cp.surname, cp.nin FROM contributor c LEFT JOIN contributorprofile cp ON cp.contributor_id = c.id WHERE c.id = :id", \['id' => $contributorId\] )->fetchAssociative(); // ── Get reason details ──────────────────────────────────────────────── $reason = $conn->executeQuery( 'SELECT reason_name, reason_code FROM withdrawal_reason WHERE id = :id AND status = :status', \['id' => $reasonId, 'status' => 'active'\] )->fetchAssociative(); if (!$reason) { return new JsonResponse(\['success' => false, 'message' => 'Invalid withdrawal reason'\], 400); } // ── Create withdrawal record ────────────────────────────────────────── $conn->executeStatement( "INSERT INTO withdrawal (contributor_id, amount, reason_id, reason_note, status, otp_verified, initiated_on) VALUES (:contributor_id, :amount, :reason_id, :reason_note, 'pending', 1, NOW())", \[ 'contributor_id' => $contributorId, 'amount' => $amount, 'reason_id' => $reasonId, 'reason_note' => $reasonNote ?: null, \] ); $withdrawalId = $conn->lastInsertId(); // ── DUMMY fa API ───────────────────────────────────────────────────── // TODO: Replace with real fa API call when credentials available // // Real fa endpoint (example — varies by fa): // POST https://api.{fa}.com/v1/withdrawal/initiate // Headers: Authorization: Bearer // Body: { // rsa_pin: contributor.rsa_pin, // nin: contributor.nin, // amount: amount, // reason_code: reason.reason_code, // reference: 'WD-' + withdrawalId // } // // Real response: { reference: 'fa-REF-123', status: 'pending', message: '...' } // ── END DUMMY ───────────────────────────────────────────────────────── $faReference = 'WD-' . str_pad($withdrawalId, 6, '0', STR_PAD_LEFT) . '-' . date('YmdHis'); $faResponse = \[ 'reference' => $faReference, 'status' => 'pending', 'message' => 'Withdrawal request received. Processing within 24 hours.', 'note' => 'DUMMY — Replace with real fa API' \]; // ── Update withdrawal with fa reference ────────────────────────────── $conn->executeStatement( "UPDATE withdrawal SET fa_reference = :ref, fa_response = :resp WHERE id = :id", \[ 'ref' => $faReference, 'resp' => json_encode($faResponse), 'id' => $withdrawalId, \] ); // ── DUMMY SMS notification ──────────────────────────────────────────── $smsMsg = "Your withdrawal request of ₦{$amount} has been initiated (Ref: {$faReference}). " . "Reason: {$reason\['reason_name'\]}. Processing within 24 hours. ."; error_log('\[DUMMY SMS\] ' . $smsMsg); // ── Audit log ───────────────────────────────────────────────────────── $audit->write($entityManager, \[ 'contributor_id' => $contributorId, 'action' => 'WITHDRAWAL_INITIATED', 'module' => 'WITHDRAWAL', 'status' => 'success', 'description' => "Withdrawal of ₦{$amount} initiated. Reason: {$reason\['reason_name'\]}", 'reference_id' => $faReference, 'request_data' => json_encode(\[ 'amount' => $amount, 'reason_code' => $reason\['reason_code'\], 'fa_code' => $contributor\['fa_code'\] ?? null, \]), \]); return new JsonResponse(\[ 'success' => true, 'withdrawal_id' => $withdrawalId, 'fa_reference' => $faReference, 'amount' => $amount, 'reason' => $reason\['reason_name'\], 'status' => 'pending', 'message' => 'Your withdrawal request has been submitted to your fa. Processing takes up to 24 hours. You will be notified via SMS.', \]); } /*** Get withdrawal history for a contributor* Route: GET /api/withdrawal/history/{id}*/#[Route('/api/withdrawal/history/{id}', methods: ['GET', 'OPTIONS'], name: 'withdrawal_history')]public function history(string $id, EntityManagerInterface $entityManager): JsonResponse{$contributorId = (int)$id;if (!$contributorId) {return new JsonResponse(['success' => false, 'message' => 'Invalid contributor ID'], 400);} $conn = $entityManager->getConnection(); $rows = $conn->executeQuery( "SELECT w.id, w.amount, w.status, w.fa_reference, w.initiated_on, w.processed_on, wr.reason_name, wr.reason_code FROM withdrawal w LEFT JOIN withdrawal_reason wr ON wr.id = w.reason_id WHERE w.contributor_id = :id ORDER BY w.initiated_on DESC LIMIT 20", \['id' => $contributorId\] )->fetchAllAssociative(); $withdrawals = array_map(fn($row) => \[ 'id' => $row\['id'\], 'amount' => bcadd((string)$row\['amount'\], '0', self::BCSCALE), 'status' => ucfirst($row\['status'\]), 'fa_reference' => $row\['fa_reference'\], 'reason' => $row\['reason_name'\], 'initiated_on' => date('d M Y', strtotime($row\['initiated_on'\])), 'processed_on' => $row\['processed_on'\] ? date('d M Y', strtotime($row\['processed_on'\])) : null, \], $rows); return new JsonResponse(\['success' => true, 'data' => $withdrawals\]); } } but i am worried it will get messy as my app grows . How do i implement the symphony best practice of Repository class in PHPmaker ?

You may want to format your posts (including those in other topics) so they can be more readable so other users can help you better.

Using markdown is recommended, if you want to paste, right click and use "Paste as plain text" may help.

Always review your post after posting to verify that it is well-formatted.

Done thank you