prepare(' SELECT s.id AS sub_id, s.membership_number, s.status, s.qr_seed, s.expires_at, u.full_name FROM subscriptions s JOIN users u ON u.id = s.user_id WHERE s.user_id = :uid AND s.status = "ACTIVE" ORDER BY s.id DESC LIMIT 1 '); $stmt->execute([':uid' => $userId]); $sub = $stmt->fetch(PDO::FETCH_ASSOC); if (!$sub) { return [ 'success' => false, 'message' => 'No active membership found for this user.', ]; } $appConfig = require __DIR__ . '/../../config/app.php'; $secConfig = require __DIR__ . '/../../config/security.php'; $secret = $appConfig['secret'] . ':' . $sub['qr_seed']; $lifetime = (int)($secConfig['qr_token_lifetime'] ?? 90); $token = Security::generateDynamicQrToken( $userId, $sub['membership_number'], $lifetime, $secret ); return [ 'success' => true, 'token' => $token, 'membership_number' => $sub['membership_number'], 'full_name' => $sub['full_name'], 'expires_in' => $lifetime, 'valid_until' => time() + $lifetime, ]; } /** * Verifies a QR token scanned by a hospital or hotel receptionist. */ public static function verifyScannedToken(string $token, int $partnerId, ?string $staffName = null): array { // Decode base64 payload to get user ID without signature first $parts = explode('.', $token); if (count($parts) !== 2) { return ['success' => false, 'message' => 'Invalid QR token format.']; } $json = base64_decode(strtr($parts[0], '-_', '+/')); $tempPayload = json_decode((string)$json, true); if (!isset($tempPayload['uid'], $tempPayload['mem'])) { return ['success' => false, 'message' => 'Corrupted token payload.']; } $pdo = Database::getConnection(); $stmt = $pdo->prepare(' SELECT s.id AS sub_id, s.membership_number, s.status, s.qr_seed, s.expires_at, u.id AS user_id, u.full_name, u.phone, u.avatar_url FROM subscriptions s JOIN users u ON u.id = s.user_id WHERE u.id = :uid AND s.membership_number = :mem LIMIT 1 '); $stmt->execute([ ':uid' => (int)$tempPayload['uid'], ':mem' => $tempPayload['mem'], ]); $record = $stmt->fetch(PDO::FETCH_ASSOC); if (!$record) { return ['success' => false, 'message' => 'Membership record not found.']; } $appConfig = require __DIR__ . '/../../config/app.php'; $secret = $appConfig['secret'] . ':' . $record['qr_seed']; $verified = Security::verifyDynamicQrToken($token, $secret); if (!$verified) { return [ 'success' => false, 'message' => 'Token has expired or signature is counterfeit. Please refresh code in app.', ]; } // Get partner discount details $pStmt = $pdo->prepare('SELECT id, name_ar, type, discount_percentage FROM partners WHERE id = :pid LIMIT 1'); $pStmt->execute([':pid' => $partnerId]); $partner = $pStmt->fetch(PDO::FETCH_ASSOC); $discount = $partner ? (float)$partner['discount_percentage'] : 50.00; // Log verification entry for audit & statistics $logStmt = $pdo->prepare(' INSERT INTO verification_logs (subscription_id, partner_id, verifier_staff_name, applied_discount, service_type, scanned_token_signature, verified_at) VALUES (:sub_id, :pid, :staff, :discount, :stype, :sig, NOW()) '); $logStmt->execute([ ':sub_id' => $record['sub_id'], ':pid' => $partnerId, ':staff' => $staffName ?: 'Front Desk', ':discount' => $discount, ':stype' => $partner['type'] ?? 'HOSPITAL', ':sig' => substr($parts[1], 0, 64), ]); return [ 'success' => true, 'is_valid' => true, 'membership_number' => $record['membership_number'], 'member_name' => $record['full_name'], 'member_phone' => $record['phone'], 'status' => $record['status'], 'expires_at' => $record['expires_at'], 'partner_name' => $partner['name_ar'] ?? 'Partner', 'discount_percentage' => $discount, 'verified_at' => date('Y-m-d H:i:s'), ]; } }