$accountId, 'gross_fils' => $gross, 'held_fils' => $held, 'available_fils' => max(0, $gross - $held), ]; } public static function placeWithdrawalHold(int $teacherId, int $amountFils, string $key): array { if ($teacherId <= 0 || $amountFils <= 0 || $amountFils > 100000000 || !preg_match('/^[A-Za-z0-9._:-]{16,128}$/', $key)) { return ['http_status' => 400, 'status' => 'error', 'message' => 'قيمة الحجز أو مفتاح الإعادة غير صالح.']; } $pdo = Database::getConnection(); $pdo->beginTransaction(); try { // This existing row serializes all holds for one teacher. Lock it // before the first consistent read, so the next hold sees this one. $teacher = Database::selectOne('SELECT id FROM teachers WHERE id=? FOR UPDATE', [$teacherId]); if (!$teacher) { return self::finish($pdo, ['http_status' => 404, 'status' => 'teacher_not_found']); } $existing = Database::selectOne( 'SELECT uuid, amount_fils, status FROM teacher_withdrawal_holds WHERE teacher_id=? AND idempotency_key=? FOR UPDATE', [$teacherId, $key] ); if ($existing) { if ((int)$existing['amount_fils'] !== $amountFils) { return self::finish($pdo, [ 'http_status' => 409, 'status' => 'idempotency_conflict', 'message' => 'استعمل المفتاح ذاته بمبلغ مختلف.', ]); } return self::finish($pdo, [ 'http_status' => 200, 'status' => $existing['status'], 'withdrawal_hold_id' => $existing['uuid'], 'replayed' => true, ]); } $balance = self::balance($teacherId); if ($amountFils > $balance['available_fils']) { return self::finish($pdo, [ 'http_status' => 409, 'status' => 'insufficient_available_balance', 'message' => 'الرصيد المتاح لا يغطي الحجز.', 'available_fils' => $balance['available_fils'], ]); } $uuid = self::uuid(); Database::insert( "INSERT INTO teacher_withdrawal_holds (uuid,teacher_id,amount_fils,status,idempotency_key) VALUES (?,?,?,'held',?)", [$uuid, $teacherId, $amountFils, $key] ); return self::finish($pdo, [ 'http_status' => 201, 'status' => 'held', 'withdrawal_hold_id' => $uuid, 'amount_fils' => $amountFils, ]); } catch (\Throwable $e) { if ($pdo->inTransaction()) $pdo->rollBack(); throw $e; } } public static function releaseHold(int $teacherId, string $holdUuid, string $reason): array { $reason = trim($reason); if ($teacherId <= 0 || !self::isUuid($holdUuid) || $reason === '' || mb_strlen($reason) > 500) { return ['http_status' => 400, 'status' => 'error', 'message' => 'بيانات إلغاء الحجز غير صالحة.']; } $changed = Database::execute( "UPDATE teacher_withdrawal_holds SET status='released',released_at=NOW(),release_reason=? WHERE uuid=? AND teacher_id=? AND status='held'", [$reason, $holdUuid, $teacherId] ); return $changed === 1 ? ['http_status' => 200, 'status' => 'released', 'reason' => $reason] : ['http_status' => 409, 'status' => 'hold_not_releasable', 'message' => 'الحجز غير موجود أو تمت تسويته.']; } private static function existingAccountId(int $teacherId): ?int { $row = Database::selectOne( "SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1", [$teacherId] ); return $row ? (int)$row['id'] : null; } private static function isUuid(string $value): bool { return (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value); } private static function uuid(): string { $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 15) | 64); $bytes[8] = chr((ord($bytes[8]) & 63) | 128); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); } private static function finish(PDO $pdo, array $result): array { $pdo->commit(); return $result; } }